🧪 Experimental feature (preview). This feature is under active development and released early so you can try it and give feedback. Behaviour, options, and results can change without notice, and it isn’t covered by the usual support and stability guarantees. Avoid relying on it for production work.
Overview
A report definition is one JSON object that describes the structure and appearance of a Report: its layout, the colour of the canvas, the title block, the tiles on the grid, and any annotations floating over them. It contains no data rows. Every tile points at the same source — "context", the records of the page the reader is looking at — and the report profiles and aggregates those records itself when it renders.
This page is for whoever writes that JSON: a person pasting a design in, or a model asked to draft a report instead of a human. It is the contract. Two companion pages go deeper:
- Report Chart Reference — every chart type, the fields each one reads, the shape of data it needs, and what the renderer does to the data on the way in.
- Designing a Report from Data — the recipe for turning a column profile into a good report: which columns to chart, which chart to use, palettes, sizes, and how to write a title and summary that say something.
The chart primitives page describes the charts themselves, independent of the report.
Where the JSON lives. Open a set of records, switch the view to Report, and click the JSON segment in the toolbar. The text box shows the current definition. Edit or paste, press Apply, and the report re-renders from what you wrote. Copy takes the current definition out. Nothing goes to the server: the definition is saved in the browser, keyed to the shape of the data you were looking at, and comes back when you return to a list with the same columns. Save the report as a Space to keep it properly.
A complete, working definition
This renders as-is against any list that has the named columns. It shows all three grid tile kinds, a filter, a legend override, and one annotation.
{
"version": 1,
"layout": "grid",
"theme": "white",
"title": {
"show": true,
"title": "Case Events Overview",
"body": "<p>247 case events across 6 event types, 4 categories and 5 regions, Jan–Jun 2026. The charts show volume by type, the category split, amount by category and region, and activity over time.</p>",
"logo": "hubstream"
},
"tiles": [
{ "chartType": "bar", "sourceId": "context", "dimension": "EventType", "measure": "", "aggregation": "count",
"size": "L", "palette": "tableau", "theme": "light", "orientation": "left", "title": "Events by Type" },
{ "chartType": "donut", "sourceId": "context", "dimension": "Category", "measure": "", "aggregation": "count",
"size": "M", "palette": "tableau", "theme": "light", "orientation": "right", "title": "By Category" },
{ "chartType": "heatmap", "sourceId": "context", "dimension": "Category", "seriesField": "Region",
"measure": "Amount", "aggregation": "sum",
"size": "M", "palette": "viridis", "theme": "light", "orientation": "left", "title": "Amount: Category × Region" },
{ "chartType": "timeSeries", "sourceId": "context", "dimension": "CreatedOn", "granularity": "month",
"measure": "", "aggregation": "count",
"size": "L", "palette": "viridis", "theme": "light", "orientation": "left", "title": "Events per Month" },
{ "kind": "hubtile", "tile": "numberLabel", "sourceId": "context", "measure": "Amount", "aggregation": "sum",
"label": "Total amount, open cases", "filter": { "field": "Status", "operator": "equals", "value": "Open" },
"size": "S", "palette": "tableau", "theme": "light" },
{ "kind": "list", "title": "Recent high-value events", "sourceId": "context",
"fields": ["EventType", "Region", "Amount", "CreatedOn"], "groupBy": "Region", "maxItems": 12, "navigate": true,
"filter": { "field": "Amount", "operator": "gt", "value": "10000" }, "autoGrow": true,
"size": "M", "palette": "tableau", "theme": "light" }
],
"annotations": [
{ "id": "anno-1", "type": "notecard", "xPct": 62, "yPx": 120, "text": "Q2 spike is the Northern region audit.", "color": "#fff7a8" }
]
}
Everything not shown is optional and falls back to a sensible default. The loader is deliberately tolerant — an unknown chart type becomes a bar, an unknown theme becomes white, a half-written filter is ignored rather than blanking the tile — so a partial or slightly wrong definition still renders. That tolerance is what makes the format practical for a model to draft. It also means mistakes are silent: watch for a tile that came out as a bar when you asked for something else.
The envelope
| Field | Values | Notes |
|---|---|---|
version | 1 | Always 1 today. Write it; nothing checks it yet. |
layout | "grid" (default) · "column" | grid is a 12-column grid where tiles take 4, 6 or 12 columns by size. column stacks every tile full-width in one centred column, ignoring size. |
theme | a canvas theme id — see below | Anything unrecognised loads as "white". |
title | object | The title block. See below. |
tiles | array | The grid tiles, in display order. There is no reordering in the UI, so the order you write is the order readers see. |
annotations | array | Stickers and notes floating over the report. Usually []. |
Canvas themes
theme sets the colour of the page the tiles sit on and the title text. Nine solid colours (no gradients, so the PDF export paints cleanly), separated by hue so they tell apart at swatch size. Each theme also decides whether the tiles’ cards default to light or dark.
theme | Label | Background | Title text | Cards default to |
|---|---|---|---|---|
white | White | #ffffff | #1a2035 | light |
parchment | Parchment | #f3e6c8 | #3a3016 | light |
sage | Sage | #cbe0c0 | #1e2e19 | light |
sky | Sky | #c2dbf7 | #13253e | light |
blush | Blush | #f6d0d8 | #3d1d24 | light |
dark-blue | Midnight Blue | #0d1e3d | #e7eefb | dark |
forest | Deep Forest | #0e2a19 | #e3efe6 | dark |
plum | Plum | #2a1030 | #f3e6f5 | dark |
black | Black | #0b0c10 | #eef1f6 | dark |
A tile that omits its own theme takes the canvas theme’s card default, so on dark-blue every tile is a dark card unless you say otherwise. Picking a theme in the toolbar also flips every tile’s card to match. Set a tile’s theme explicitly only for deliberate contrast.
Gotcha.
"theme": "light"and"theme": "dark"are not theme ids. Older definitions and the built-in auto-builder write them; they load aswhite. Use the ids above.
The title block
"title": { "show": true, "title": "Case Events Overview", "body": "<p>…</p>", "logo": "none" }
| Field | Values | Notes |
|---|---|---|
show | true (default) · false | Hide the block entirely. Only an explicit false hides it. |
title | string | The heading. Rendered as a single line; keep it short. |
body | HTML string | The summary under the heading. It’s a rich-text editor on screen, so the value is HTML, not markdown. |
logo | "none" (default) · "hubstream" · "dataspace" | Logo drawn above the title. It inverts automatically on the dark themes. |
What to put in body. The editor’s own toolbar produces bold, italic, bulleted lists, text colour and highlight, so stick to that vocabulary and it will round-trip through the editor unchanged:
<p>247 records across 7 fields. <b>Event Type</b>: 6 values — Seizure (120), Complaint (80), Referral (30).</p>
<ul><li>Spanning <i>Jan 2026–Jun 2026</i>.</li><li>Total Amount: <span style="background-color:#fff15c">1,234,567</span>.</li></ul>
Allowed and safe: <p>, <b>/<strong>, <i>/<em>, <ul>/<li>, <br>, and <span style="color:#hex"> / <span style="background-color:#hex">. Keep it to one or two short paragraphs; the block is a header, not a page.
What every grid tile shares
There are three kinds of grid tile. kind says which; a tile with no kind is a chart, because chart tiles predate the other kinds and old definitions have no kind at all. The builder writes chart tiles without one, too.
| Field | Values | Applies to | Notes |
|---|---|---|---|
kind | omitted or "chart" · "list" · "hubtile" | all | Unknown values load as chart. |
sourceId | "context" | all | The one data source: the records in view. Anything else is replaced with it. |
size | "S" · "M" · "L" | all | Grid columns out of 12: S = 4, M = 6, L = 12. Two Ms share a row; three Ss share a row; an L is a full-width banner. Defaults: M for charts and lists, S for hosted tiles. Ignored in column layout. |
palette | one of 13 palette ids | all | Default ember. See palettes. |
theme | "light" · "dark" | all | The tile’s card. Defaults to the canvas theme’s card default. |
title | string | all | The heading on the card. Charts derive one when it’s missing ("Count by EventType", "sum of Amount by Region"); lists must supply one; hosted tiles use it only for the Model Summary heading. |
filter | object | all | Narrows the records this one tile reports on, before anything is aggregated. See Filters. |
autoGrow | true | all | Size the card to its content instead of the fixed height so nothing scrolls inside it. Write it only when true. |
Every tile is 300 px tall. That’s the framed card height in both layouts, and it’s the single most important constraint on design: charts must read at 300 px, so few categories and short labels win. autoGrow lifts the cap (with a 240 px floor) and is most useful on a list, where it lets every row show.
Chart tiles
A chart tile is chartType plus the fields that chart’s family reads. Set only what the family needs; the rest is ignored.
{ "chartType": "groupedBar", "sourceId": "context",
"dimension": "Category", "seriesField": "Region", "measure": "Amount", "aggregation": "avg",
"size": "M", "palette": "tableau", "theme": "light", "orientation": "left",
"title": "Average amount by category and region" }
| Field | Values | Read by | Notes |
|---|---|---|---|
chartType | one of 36 ids | — | Unknown values load as bar. Full list and per-chart guidance in the Report Chart Reference. |
dimension | column name | most families | The group-by / category axis / slice label. For the time, calendar and candlestick charts it’s the date column. For a sankey it’s the fallback first stage. Not used by scatter, bubble, histogram or gantt. |
measure | column name or "" | most families | The numeric column to roll up. Ignored when aggregation is count — write "". Required (raw values, no aggregation) by boxplot, histogram and candlestick. |
aggregation | "count" (default) · "sum" · "avg" | aggregating families | How measure is rolled up per group. Not used by scatter/bubble, boxplot, histogram, candlestick or gantt. |
seriesField | column name | series, matrix, sunburst, xy, gantt | The second dimension: the series split of a grouped/stacked bar or radar, the rows (Y) of a heatmap, the outer ring of a sunburst, the colour split of scatter/bubble/gantt. |
stageFields | ["A","B","C"] | sankey, network, flow | Ordered flow stages, at least two. Links aggregate between every consecutive pair, so a sankey is multi-column, not just from→to. Falls back to [dimension, seriesField] when absent. |
xMeasure, yMeasure | column names | scatter, bubble | Two genuinely numeric, varying columns. |
sizeMeasure | column name | bubble | Bubble area. |
granularity | "day" · "week" · "month" (default) · "quarter" · "year" | timeSeries, timeBar, cumulative, burndown | The calendar period the date axis is bucketed into. The calendar chart is always daily. |
startField, endField | date column names | gantt | The span each bar covers. |
labelField | column name | gantt | Names each bar; rows fall back to #1, #2, … |
orientation | "left" (default) · "right" | charts with a side legend or label list | Which side the legend sits on. |
showLegend | false | charts that draw a legend | Hides it. Only false is meaningful; omit for shown. Axis charts fall back to labelling the axis. |
Column names must match the data exactly, including case. A dimension that doesn’t resolve to a real column self-heals to the first low-cardinality text column once the data loads; a missing X/Y measure picks the first numeric non-key columns. That keeps a tile from rendering blank, but it isn’t your design — set the fields.
List tiles
A list of records, not a summary of them: one row per record, printing the chosen columns in order, separated by |, each column in its own palette colour so it reads down the list. Rows link to their record.
{ "kind": "list", "title": "Open tasks", "sourceId": "context",
"fields": ["Name", "AssignedTo", "DueDate"], "groupBy": "Status", "maxItems": 10, "navigate": true,
"size": "M", "palette": "slate", "theme": "light", "autoGrow": true }
| Field | Values | Notes |
|---|---|---|
title | string | Required in practice — a list has nothing to derive one from. |
fields | ["Col", …] | Columns printed per row, in this order. Duplicates are dropped. Empty cells are skipped rather than printing stray separators. Left empty, the tile picks the first three non-key columns. |
groupBy | column name | Splits the list into sections, one heading per distinct value, headings sorted A→Z. Empty values group under (none). Omit for a flat list. |
maxItems | 1–50, default 8 | Row cap. Records are taken in the order the data arrives, up to the cap, then grouped — so a low cap with groupBy can leave some groups empty. A report shows a readable extract, not the whole result set. |
navigate | true (default) · false | Make each row a link to its record. Rows whose record can’t be resolved simply render unlinked. |
Palette colours are chart fills, so a pale one can be unreadable as text on a white card. The list checks contrast and gives any failing colour a small backing chip rather than recolouring it, so a column keeps the same colour it has in the charts.
Hosted Hubstream tiles
Some of the platform’s own tiles can sit in a report, bound to the report’s records instead of a server query. They are the real tiles, so they behave as they do anywhere else. Default size is S; they read best small.
{ "kind": "hubtile", "tile": "countdown", "sourceId": "context",
"measure": "", "aggregation": "count",
"dateField": "Deadline", "countdownMode": "countdown", "label": "Next deadline",
"size": "S", "palette": "ember", "theme": "light" }
tile | Shows | Reads |
|---|---|---|
numberLabel | One big rolled-up figure with a caption | measure + aggregation (count of records, or sum/avg of measure); label is the caption; palette colours the figure. |
modelSummary | The record count plus a field breakdown | title as its heading; measure + aggregation as the first line; then up to maxItems (1–25, default 5) columns as “N distinct”. |
countdown | A live timer to or from a date | dateField; countdownMode "countdown" (default, counts to the next upcoming date in the column) or "elapsed" (counts up from the most recent past one); label is the caption. |
marquee | Scrolling text | textField; the first maxItems non-empty values scroll; label becomes a static title. Prefer a text column — a marquee of numbers reads as noise. |
measure and aggregation are always present on a hosted tile (write "measure": "", "aggregation": "count" when they don’t apply). urlField is accepted and reserved; nothing reads it yet. A filter works on every hosted tile, which is how you get “total amount, open cases” as a single number.
Filters
Any grid tile can carry one condition that narrows the records it reports on, applied before aggregation and without touching the page’s own query. One condition, deliberately — it’s a report-authoring convenience, not a query builder. The tile shows a line under its title so a filtered chart can’t be mistaken for the whole picture: Filtered by Status is "Open".
"filter": { "field": "Status", "operator": "equals", "value": "Open" }
operator | Reads as | Needs value |
|---|---|---|
contains (default) | contains | yes |
notContains | does not contain | yes |
equals | is | yes |
notEquals | is not | yes |
startsWith | starts with | yes |
gt | is greater than | yes |
lt | is less than | yes |
isEmpty | is empty | no |
isNotEmpty | is not empty | no |
Matching is case-insensitive throughout. gt/lt compare numerically when both sides parse as numbers, otherwise as strings — which means they work on ISO-style dates ("2026-03-01") but not on 12/03/2026-style ones. A filter with no field, or a value-taking operator with an empty value, is ignored rather than filtering everything out.
Annotations
An overlay of stickers and notes floating above the tiles, positioned so they stay put across screen widths: xPct is a percentage of the content width, yPx is pixels from the top of the report content. They’re saved in the definition and exported with the PDF — see Annotations.
{ "id": "anno-1", "type": "sticker", "xPct": 88, "yPx": 40, "sticker": "🔥" },
{ "id": "anno-2", "type": "sticker", "xPct": 12, "yPx": 400, "image": "https://stickers.hubstream.one/api/public/stickers/<id>/image" },
{ "id": "anno-3", "type": "notecard", "xPct": 55, "yPx": 360, "text": "Northern region audit.", "color": "#bce7ff" },
{ "id": "anno-4", "type": "comment", "xPct": 30, "yPx": 700, "author": "J. Milan", "text": "Check the March dip." }
| Field | Values | Notes |
|---|---|---|
id | string | Unique; missing ids are generated. |
type | "notecard" (default) · "sticker" · "comment" | The comment button is currently hidden from the toolbar, but the type still renders when it’s in the JSON. |
xPct | 0–100, default 40 | Horizontal position. |
yPx | ≥ 0, default 80 | Vertical position. A 300 px tile plus grid gap is a useful unit: row two starts around y ≈ 330 below the title block. |
text | string | Notecard / comment body. |
author | string | Comment attribution; the avatar shows initials. |
sticker | an emoji | Emoji sticker. |
image | URL | Image sticker from the sticker service. Wins over sticker when both are set. |
color | CSS colour | Notecard background. The picker offers #fff7a8 (default), #bce7ff, #c7f5cf, #ffd6d6, #111827, #1e3a8a; text flips to white on the dark two. |
If you’re drafting without seeing the rendered page, default to "annotations": []. Blind placement rarely lands where you meant.
How the loader treats what you write
The parser normalises every field against its allowed set and falls back rather than failing. Knowing the fallbacks tells you what a wrong value silently becomes:
| You wrote | It loads as |
|---|---|
unknown chartType | bar |
unknown theme (canvas) | white |
unknown tile theme | the canvas theme’s card default |
unknown palette | ember |
unknown size | M (charts, lists) / S (hosted) |
unknown aggregation | count |
unknown granularity | month |
unknown kind | chart |
unknown tile (hosted) | numberLabel |
unknown filter operator | contains |
sourceId other than "context" | "context" |
maxItems out of range | clamped to 1–50 (list) / 1–25 (hosted) |
show, navigate missing | true |
| non-string where a string is expected | "" |
Optional string fields are written back only when non-empty, and showLegend / autoGrow only when they change the default, so a definition copied back out of the JSON view is a little leaner than what you pasted in. That’s expected.
Preview against real data
Don’t write a definition blind. The view has a built-in loop:
- Navigate to the records the report is for — a group of cases, the tasks under a case — and switch the view to Report. It auto-draws a starting report from the data.
- Click JSON in the toolbar. You’ll see the auto-built definition. Its column names are real, so it’s a handy way to read the exact spellings.
- Paste your draft and press Apply. The report renders immediately. A tile that says No data in context has data but nothing in the fields you named; one that came out as a bar chart had an unknown
chartType. - Iterate, then Copy. Applying marks the report as authored: it’s saved in the browser for lists with this set of columns, and the auto-builder stops regenerating over it. Reset throws that away and rebuilds from the data.
- Save as a Space to keep it, or Save to PDF to export.
Checklist before you ship one
- JSON parses (Apply is the test).
-
version: 1,layoutisgridorcolumn,themeis one of the nine ids (notlight/dark). - Every
sourceIdis"context". - Every column name —
dimension,measure,seriesField,stageFields,xMeasure/yMeasure/sizeMeasure,startField/endField/labelField, listfields,groupBy, filterfield,dateField,textField— is spelled exactly as the data has it. -
counttiles havemeasure: "";sum/avgtiles name a real numeric column. - Each chart has the fields its family needs (see the reference); nothing else.
- Filters name a
fieldand, for value operators, a non-emptyvalue. -
title.titlenames the subject;title.bodyis short HTML with real facts, not placeholder text. -
annotationsis present —[]unless you can see where they land.