The sibling of Handlebars chart templates and Power BI templates, but for a target with no BI-tool import step: the artifact is the whole product. Upload one data file (≤ 50 MB, or ≤ 100 MB at ×2 the price), pick a theme, get back one .html file — filters, KPIs, charts, a data table, cross-filtering — that opens offline, in any browser, forever.
Implemented in the generator module (the Superset engine, :8085) as one more BundleExporter: BiToolType.HTML, package v1. No new bootable app, no new database.
1. What is in the file
<!doctype html>
<meta charset, viewport, Content-Security-Policy (default-src 'none'; …)>
<title> the dashboard name, HTML-escaped
<style id="dg-base"> the Apache Superset DOM vocabulary every theme already styles
<style id="dg-tokens"> :root { --dg-bg: …; } — the theme's fifteen colour tokens (§5a), or empty
<style id="dg-theme"> the resolved theme's CSS, verbatim — EMPTY when a design is present
<style id="dg-fonts"> a design's embedded fonts as @font-face over data: URIs, or empty
<style id="dg-design"> a design's CSS, written against the tokens, or empty
<style id="dg-runtime"> dg-prefixed layout: filter bar, chips, KPI tiles, table
<div id="dg-root"> filled by runtime.js at load
<script type="application/json" id="dg-spec"> the dashboard spec (§2)
<script type="…json|gzip+base64…" id="dg-data"> the embedded data (§3)
<script id="dg-echarts"> echarts.common.min.js 5.6.0, vendored verbatim
<script id="dg-handlebars"> handlebars.min.js 4.7.9 — only when a chart uses a library template, else empty
<script id="dg-runtime"> query.js + runtime.js, concatenated
Nothing in the file loads anything from outside it: no CDN, no @import, no url(http…). A test (HtmlV1BundleExporterTest.theDocumentReferencesNothingOutsideItself) enforces this on every build. The CSP tag is a second, browser-enforced guarantee. A file in which a chart is drawn by a library template (§4, Template charts) adds 'unsafe-eval' to its script-src: Handlebars compiles a template into a function, which the browser refuses without it. Inline scripts are allowed in every file already, so this lets no script run that could not run before, and a file without a template keeps the stricter policy byte for byte.
The one outside address it carries is the attribution: a Made with DASH:Gen · dashgen.de link in the header's meta row, next to the row count and the theme name. It is a navigation and nothing else — no script, no image, no request — and theOnlyExternalAddressIsTheAttributionAndItLoadsNothing asserts that it is the only one the exporter writes. Two settings govern it, and emptying either removes the line completely, which is the right default for a self-hosted deployment:
generator.html.attribution-url=${HTML_ATTRIBUTION_URL:https://dashgen.de/?ref=dashboard}
generator.html.attribution-label=${HTML_ATTRIBUTION_LABEL:Made with DASH:Gen · dashgen.de}
The reason it is there at all: every dashboard is opened by people who did not generate it, which makes the artifact the one distribution channel this product has for nothing (T7). The link is written with rel="noreferrer", so a local file path never leaks; the ?ref=dashboard in the address is what lets a visit from a dashboard be counted in the server's access log instead, and the site removes it from the address bar on arrival.
2. The dashboard spec
Built by HtmlDashboardSpecBuilder from the same DashboardDesign every exporter reads. Charts are validated against the dataset exactly like Apache Superset's ChartDatasetValidator — an invalid reference drops the chart with a reason (ExportReport.dropped), never the whole file. Shape:
{ "version": 1, "name": "Sales Dashboard", "generatedAt": "…", "style": {"name": "…", "colorScheme": "…"},
"rowCount": 234567, "embeddedRows": 234567, "truncated": false,
"tabs": [ {"name": "Overview", "rows": [ {"cells": [ {"chartIds": ["k1"], "width": 4, "heights": [null]} ]} ]} ],
"charts": [ {"id": "k1", "type": "KPI", "title": "…",
"metrics": [ {"name": "sum__revenue", "column": "revenue", "aggregation": "SUM"} ],
"dimension": null, "temporalColumn": null, "columns": [], "sortColumn": null,
"showTotals": false, "filter": null, "columnDimension": null} ],
"filters": [ {"id": "f_order_date", "kind": "DATE_RANGE", "column": "order_date", "label": "order date"},
{"id": "f_region", "kind": "MULTI_SELECT", "column": "region", "label": "Region",
"defaultValue": {"values": ["DACH"]}, "excludedChartIds": ["k1"]} ],
"mainTable": "t1", "interactions": {"crossFilter": true} }
- Main table, always. The design's own raw table if it has one, else its last aggregate table, else a synthetic
TABLEover every column on a new "Data" tab (HtmlDashboardSpecBuilder.DATA_TABLE_ID). It is always widened to the full grid width. (R5.10) - A matrix (
PIVOT) reads three fields.dimensionis its first row header,columnsthe rest of them, andcolumnDimensionthe field whose distinct values become the columns. Row headers may be any column — an object number is a label, not a groupby — while the column dimension must be groupable, which in the browser also means it is dictionary-encoded and so clickable. A matrix with neither, or with a non-groupable column dimension, is dropped with a reason like any other invalid chart. - A two-axes chart (
DUAL_AXIS) reads its metrics and a date, and splits the metrics between two scales. By default the first is drawn as columns against the left axis and the rest as lines against the right one, so two measures of different units or magnitudes — a revenue and a count, say — stay readable side by side instead of one flattening the other. An optionaldualAxisobject, set in the editor (§10.9), changes that:{"leftShape": "LINE", "rightShape": "AREA", "leftSeries": 2, "shift": 1, "cumulative": true}— each side's shape (BAR,LINE,AREA), how many of the metrics in order are the left axis's (one to three per side, five in all), the right axis moved by that many periods of the shown grain (−12…12; +1 draws next period's value at this one, so work done in March sits beside the money that arrived in April), and every series as a running total from the first period shown. The key is left out when every field is at its default, so a file from before it stays byte-identical. It needs atemporalColumn, a measure on each side and no measure on both; a chart missing any of those is dropped with the reason ("no date column","needs a measure on each axis","at most 3 measures per axis","the same measure on both axes"). Each axis label takes its first series' colour, so the pairing survives a look that hides the legend. - No derived metrics, but calculated measures. The runtime evaluates no expressions: a chart on a derived metric written as SQL is dropped with reason
"derived metric '…'"(R5.8). A calculated measure made in the editor (§10.9) is drawn: the spec carries it under"calculated": [ {"name": "calc__cost_per_hour", "label": "Cost per hour", "operands": ["sum__cost", "sum__hours"], "postfix": ["{sum__cost}", "{sum__hours}", "/"], "decimals": 2} ]— operands the runtime aggregates like any measure, and a postfix list it walks per group, so a formula is data and never code. The key is left out when no drawn chart reads one, so such a file is byte-identical to before. A chart's metric then reads{"name": "calc__…", "column": null, "aggregation": "CALC"}. - No synthetic date parts.
HtmlCapabilitiesnever offers<col>__month— the runtime buckets a temporal axis itself (§4), so the agent is never tempted to group by a column the file does not carry. - Filters are derived unless the editor chose them. A design with no filters of its own (
DashboardDesign.filtersnull — every generated one) getsHtmlFilterDeriver's panel: oneDATE_RANGE(the temporal column referenced by the most charts) and up to sixMULTI_SELECTs (chart dimensions, ranked by reference count then lower cardinality, excluding anything over 200 distinct values). No agent tool produces it. (R5.15, R5.16) A design whose filters were chosen in the editor (§10.9) carries them instead —VALUE(written asMULTI_SELECT),DATE_RANGE,NUMBER_RANGE, at most twelve and one date range — with an optionaldefaultValueand the charts each one leaves out (excludedChartIds); an empty list is a dashboard without a panel. A chart may carry"crossFilter": "RECEIVE_ONLY"or"IGNORE". Each of these keys is left out when unset, so a derived dashboard's spec is the one it always was.
3. The data payload
HtmlDataPayloadBuilder turns the upload into columns the browser can compute over without re-parsing text: numbers as JSON numbers (NaN→null), dates as epoch days (LocalDate. toEpochDay()), booleans as 0/1, and strings as {dict, codes} once the dictionary would save more than half its footprint — otherwise a plain array. Below generator.html.inline-payload-max-kb (default 1024 KB) the payload stays application/json; above it, application/gzip+base64, inflated in the browser with DecompressionStream('gzip') (Chrome 80+, Firefox 113+, Safari 16.4+ — older browsers see a plain-text notice, .dg-unsupported, instead of a blank page).
Two caps bound the worst case, both in generator.html.*:
| Property | Default | What it bounds |
|---|---|---|
generator.html.max-rows | 1 000 000 | rows read from the upload, in file order |
generator.html.max-payload-mb | 40 | the gzipped payload; over it, rows are shed in 10 % steps until it fits |
generator.html.inline-payload-max-kb | 1024 | the plain-JSON / gzip threshold |
When either cap bites, the file says so honestly: spec.truncated = true, spec.embeddedRows < spec.rowCount, and the header shows "Showing the first N of M rows". No sampling that would silently change a sum — always the first rows kept, in file order (OQ-1 in the requirements discusses smarter reduction for a later version).
Rows come from the parser SPI's DatasetParser.streamRows(in, fileName, schema, sink, maxRows) — every row, not the 200-row preview parse() keeps — implemented for delimited text (.csv, .tsv, .txt, and their .gz), Excel (.xlsx, .xlsm, .xls), OpenDocument .ods, JSON, JSON Lines and XML, and Parquet. A workbook streams the sheet its schema records, so every version of a dashboard reads the same one; an Apache Superset export ZIP has no rows and its streamRows throws UnsupportedOperationException (the exporter turns that into a 415 before it ever calls it).
4. The runtime ({query,runtime})
Split in two, deliberately:
query.js— the pure data layer: decoding, filter masks,aggregate/totals, date bucketing (epoch-day arithmetic, noDateobjects per row), row search/sort, URL-hash (de)serialisation, number/label formatting. No DOM, no ECharts — sonode --test src/test/js/query.test.mjsruns it directly, and does (71 cases). The Java suite runs the same file viaHtmlQueryScriptTest, opt-in like the Python skill-validation tests (assumeTrue(nodeAvailable())).runtime.js— the DOM and ECharts glue: builds the page from the spec, renders each chart type's ECharts option (ported from the studio preview'schartOptions.ts, so the file and the preview draw the same picture from the same spec), the filter bar controls, the main table, export-to-CSV, print. Every value that came from user data reaches the DOM throughtextContentor an ECharts data array. The one exception is a library template's rendered output, which Handlebars has already escaped (Template charts below);HtmlRuntimeResourcesTest. theRuntimeInterpolatesNoMarkuppins that there is exactly oneinnerHTML, and where it is.- A Notes tab, when the design agent explained itself (T14). The design tool
explainDashboard(summary, charts)records one sentence for the dashboard and one per chart (what it answers, why these columns); they travel asDashboardDesign.rationaleandChartSpec.rationaleintoHtmlDashboardSpec.rationaleand each chart'srationale. The runtime adds a last tab,Notes, past the end ofspec.tabsso it holds no chart: the summary, the rows / generated date / theme, then each explained chart's title and sentence — built withtextContentlike everything else. A design with no sentence at all gets no tab. The same text is each Apache Superset chart'sdescription, the text the Apache Superset dashboard's Handlebars tab opens with, and the "WHY THESE CHARTS" section of the Power BIREADME.txt; the app shows it under the result as "Why these charts". - One message out, and only when framed (T12). With
state.embedded(window.self !== window.top) a chart title is a button that posts{type: 'dg:chart-selected', chartId, chartType}to the parent window — the chart's id and type, never a row. A file opened on its own, or served as a shared page (§10.8), is not framed and posts nothing.HtmlRuntimeResourcesTest.theChartSelectedMessageIsSentOnlyWhenEmbeddedAndCarriesNoDatapins that the pick'spostMessagesits inside the embedded check, with no table in it. - A second message in, to try a hand edit on (ED-T6). Under the same guards as the look try-on below — only when framed, only from the parent — the runtime accepts
{type: 'dg:try-design', spec: {name, tabs, charts, mainTable, calculated}}: it swaps those parts of its spec, rebuilds the page and redraws, keeping the file's own data, filters and look; withfocus: <chartId>it draws that one chart alone and full size, with no header, tabs or filter bar, and its clicks filter nothing — the chart editor's preview (DE-R8.5.1);{type: 'dg:try-design', restore: true}puts the built design back. Either way it replies{type: 'dg:design-applied', name}. Nothing is persisted — a reload, a download and a shared page all draw the design the file was built with. The spec it is sent comes from the engine's dry-run (§10.9), never assembled in the browser. A file built before this handler existed does not answer, and the editor says the change shows after the rebuild. - A third message in, to edit in place (ED-T10, HE-R5). Same guards again:
{type: 'dg:edit-mode', on, selected?}turns the editor's handles on: every widget's header becomes its drag handle, the right edge, the bottom edge and a ◢ corner resize it, blue lines mark where a dragged widget lands, the tab bar shows with +, a rename on double-click and a ⋯ menu, and an empty tab says Drop a widget here. The added data table and the widened main table's width are left alone.selectedmarks a widget and brings it into view without a redraw. The frame never owns the layout: everything done on it leaves as{type: 'dg:layout-intent', intent}, and the window answers withdg:try-design, which is what actually redraws. The intents are list positions and sizes (move,width,height), a chart's design id (select,nudgewith a direction, ✎edit, ⧉duplicate, 🗑remove,move-to-tab), and the tab names a person typed (tab-add,tab-rename,tab-move,tab-delete) — never a row, a value or a column. Tabs travel by name, because the drawn tabs may end with the added data tab. Every intent leaves through one function, and the mode replies{type: 'dg:edit-mode-applied', on, chrome: 'edit-chrome'}; a file from before these handles answers withoutchrome. While editing, a click on a chart selects it rather than filtering, and a click on a title still sendsdg:chart-selected, which opens the widget's card.HtmlRuntimeResourcesTest.theCanvasSendsExactlyThePinnedIntentKindsAndNoDatapins the list. - One message in, to try a look on (LK-R5.1). Only when framed, the runtime listens for
{type: 'dg:try-look', look: {name, css, fontsCss, chart} | null, theme: {name, colorScheme, tokens, css}}and answers nothing unlessevent.sourceis its parent window. It fills the four look slots (dg-tokens,dg-theme,dg-fonts,dg-design) by the assembler's own rules — a design empties the theme CSS, Classic empties the design — swaps the root'sdg-look-…class, replacesspec.lookand redraws every chart.{type: 'dg:try-look', restore: true}puts the file's own look back. Either way it replies{type: 'dg:look-applied', name}. Nothing is persisted: a reload, a download, and a shared page (§10.8, servedX-Frame-Options: SAMEORIGIN, so its only possible parent is the site) all draw the look the file was built with. A file built before this handler existed simply does not answer, and the window falls back to rebuilding without a preview.HtmlRuntimeResourcesTest. theLookTryOnListensOnlyWhenEmbeddedAndOnlyToTheParentpins the guard.
Filters and cross-filter
The filters sit in a panel on the left, the way Apache Superset's do: on top the Cross-filters — every value set by a click on a chart, with the chart's title under it (a click on the title brings the chart into view) and × — then the Filters, one control each, in the design's order. Clear all in the panel and Reset filters in the header remove everything. The panel folds to a narrow rail with its count, and a dashboard with no filters draws no panel until a click sets a cross-filter. On a frame narrower than 900 pixels — a phone, a narrow window, the app's result column — the panel is a drawer the header's Filters (n) button opens, and the active filters show as chips under the header. The panel keeps the class names every theme and design already styles (filter-box, dg-filter, dg-chip), so it takes the look without a change of its own.
Every chart says what filters it. A badge ⧩ n in its header counts the filters that reach it; hovering or clicking it lists them — Applied filters, Applied cross-filters with the chart that set each, Highlighting for a clicked chart's own value, Not applied for what its scope leaves out — each with ×. A printout leaves the panel and the badges off and prints one line under the header instead: Filtered: Region: DACH (from "Revenue by region") · … — a filtered page that did not say so would read as the whole data.
- No Apply button, unlike Superset's native filters. Superset waits because every change is a query against a database; here a change is a mask over the rows already in memory, so every change applies at once.
- Defaults apply when the file opens with no filter in its address; a value the data does not hold is skipped (a delivery to a situational dashboard may lack it), and a date default may be a quick range counted back from the latest day the data holds. A bookmark always means exactly what it says.
- Scope. A filter may leave charts out (Applies to in the editor), and a chart may be set to Is filtered only (its clicks filter nothing) or Ignores cross-filters (only the panel reaches it). Charts left out of the same filters share one mask per frame. A KPI compares with the previous period only when the date filter reaches it.
- The panel's controls are
DATE_RANGE,MULTI_SELECTandNUMBER_RANGE(two number fields, inclusive, an empty one open); every change rebuilds aUint8Arraymask and redraws every visible chart in onerequestAnimationFrame. - Emitters: BAR, PIE, FUNNEL (category or legend click), any TABLE (row click for an aggregate table, cell click on a dictionary-encoded DIMENSION column for a raw one) and PIVOT (column header filters by the pivoted field; a row header filters by its own field when that column is dictionary-encoded too), and a template chart whose template marks elements with
data-dg-value(Template charts below). LINE, AREA, SCATTER, DUAL_AXIS and KPI never emit, and neither does a bar, pie or funnel whose dimension is a date or numeric column —buildMaskapplies a value set to dictionary columns only, so such a click would show a chip that filters nothing. - A date row of a table or a matrix — a month, a quarter, a week, as the widget's date step groups it — emits too: the click sets the range of that bucket on the date column (
2026-03-01..2026-03-31), listed under Cross-filters asDate: 2026-03with its chart, and a second click on the same row clears it. The clicked table keeps every month and marks the one chosen; a date range set in the panel still filters it. - The emitting chart excludes its own column — and any cross-filter it set itself — from its own mask and instead highlights the selected categories (full opacity) and dims the rest (
itemStyle.opacity 0.35) — the Apache Superset/Power BI convention that keeps the other categories clickable. - A cross-filter click and the matching panel control set exactly the same state object: click a bar, see the multi-select tick; tick the multi-select, see the bar dim. One state per column, two ways to set it — a click records which chart set it, and a change in the panel takes it over. Both are serialised into
location.hash(#region=DACH&order_date=2026-01-01..2026-06-30&amount=10..200) so a filtered view can be bookmarked; which chart set a value travels under one reserved key,dg-cf=region:c3, which a file from before it simply ignores. "Reset filters" clears it in one action, both panel and clicks. spec.interactions.crossFilteristrueon every export today; the flag exists so a future option can turn it off per dashboard without a runtime change.- Dates and numbers follow the reader's browser, not the file. The two date fields of a date-range filter are the browser's own date controls, so they appear in the browser's language and date order: a German Chrome shows
tt.mm.jjjj, an English onemm/dd/yyyy. Numbers on the charts are formatted the same way —27,7 Mio.in German,27.7Min English. Both are the reader's settings; the file is the same for every reader, and a range it writes into the fields itself (a quick range, a bookmarked#order_date=…) is always an ISO date underneath.
The widget menu: download as CSV, open full screen
Every widget has a ⋮ at the end of its header, as in Superset, with two items:
- Download as CSV — what the widget computes, not the rows behind it: a bar chart's groups, a time axis's periods at the grain on screen, a KPI's value (and the period before, with the change in per cent, when the card shows them), a table's groups or rows, a matrix as wide as it is drawn, a template's rows. Every group and every page: a bar chart past its 30 bars and a pie past its 12 slices write them all, and a pie writes no Other. It is computed at the click under the filters and cross-filters that reach the widget, through the same functions the drawing uses, so the file agrees with the screen. The dialect is the one a German or Russian Excel opens on a double click:
;between cells, a decimal comma, no thousands separator and no exponent, datesYYYY-MM-DD, UTF-8 with a BOM. The name is<dashboard> - <widget>.csv, with(first N rows)added when the file holds only part of the data. The header's Export CSV — the added data table — writes the same dialect. - Enter fullscreen — the widget alone over the page until ✕, the menu's Exit fullscreen or Esc. It stays live: filters still reach it and its clicks still filter. In the app's window the result frame grows to the whole window with it.
Neither item is in the editor's canvas, in the chart editor's one-chart view, or in print. Inside the app the frames are sandbox="allow-scripts allow-downloads": the download is the only thing added, the dashboard still runs in an opaque origin.
The matrix
Q.pivot(table, mask, {rowDimensions, columnDimension, metrics, maxColumns}) builds the crosstab in one scan, reusing the dictionary codes the columns already carry — two integer lookups per row, no new data structure. It returns the kept column values, a row per row-header combination with one cell each, both totals axes, and how many columns the cap left out.
The cap is 50 columns (MAX_PIVOT_COLUMNS in runtime.js) and the runtime says so under the table when it bites. This is the only exact cardinality check anywhere in the pivot path: the design agent is told to pick a field whose values repeat, but nothing before the runtime knows the real distinct count, and the file is opened offline with no way to ask for a narrower one.
Tables and matrices: date steps, sort, bars and colours
A table or a matrix grouped by a date groups it by a step — day, week, month, quarter or year, D · W · M · Q · Y in the widget's header — and lists the steps oldest first unless told otherwise. The table's own sort follows its group field: dates chronologically, text A → Z, numbers by size, an empty value last, and a click on a column header sorts by that column (the group column by its value, not by the measure). A matrix lists its row headers by value, or by a measure's total when that measure is its sort, and its column headers always by value — months as columns read January to December.
A measure column may show cell bars: each cell a bar as long as its value against the largest absolute value of the column among the rows on screen. A column with negative values draws from a middle zero line, negatives to the left in the theme's negative colour. Totals carry no bar, and a matrix measures its bars against the whole matrix.
Up to ten colour rules per widget, tried in order, the first match winning:
- a rule on a measure —
>,≥,<,≤,=,≠, between — or on a text field — is, is not, is one of — colours the cell Good, Bad, Warning or Accent; on a table it may colour the whole row instead; - a colour scale on a measure shades a column from light to strong in one colour, or from bad to good around a middle value.
The four colours are the theme's own tokens, never a code, so a rule reads the same in a light and a dark theme. A rule on a column the widget no longer shows is dropped with the column.
On an Apache Superset bundle the date step, the sort, the bars, the numeric rules and the one-colour scale become Superset's own settings; what Superset cannot express — a whole-row colour, a text rule, a bad-to-good scale, bars on a matrix — is listed in table-formatting inside the bundle.
Time
A temporal chart's axis buckets by day when the filtered span is ≤ 92 days, by month up to 3 years, by year beyond — Q.autoGrain(min, max) — with a D · W · M · Q · Y switch in the chart's own header to override it per chart. A KPI_TREND tile shows a ▲/▼ delta against the immediately preceding period of equal length, when a date-range filter is active.
Template charts
A chart can be drawn by a Handlebars template from the chart-template library — the same library, the same template bodies, that Apache Superset charts use (see Handlebars chart templates). A template reaches a chart in one of two ways: a person pins it as a version of the dashboard (§10.3), or the design agent pins it while designing the first version.
- The agent's pins. On an HTML run the design agent is shown the templates the caller may use — the built-in ones, the caller's own, and everyone's public ones, at most fifteen, each with its description and its When to use it sentence — and may pin one per chart with a tool of its own,
pinTemplate. It is asked to give the Overview KPI row a template when a KPI template is offered, to template at most one further chart per tab, and to leave the data table alone unless aRAWtable template is offered. A pin that does not fit the chart is refused on the spot, and one written straight into the design is rejected by the same rules the design's other fields are; when the repair rounds run out the chart is exported standard with a note. A new instruction on a finished dashboard (§10.3, a refine) designs anew with the same offer, computed for whoever asked for that version. Apache Superset and Power BI runs are offered nothing and their prompts are unchanged. Undoing an agent's pin is the picker's "Standard" section, free, like any other look change. - What travels. The exporter looks the pinned name up when the file is built and carries the template's Handlebars and CSS in the chart's spec entry (
template: {name, queryMode, handlebars, css}), so the file never has to ask for it. The lookup ignores visibility: a template its author took private after the pin still draws the dashboards that already use it. - When it does not apply. An unknown or disabled template, one built for another chart type, or a
RAWtemplate on a chart with no column list leaves the chart in its standard type — never dropped — and the export report carries a note saying which and why. - The rows.
Q.templateRowshands the template{ data: rows }, shaped as Apache Superset's plugin shapes them. AnAGGREGATEtemplate gets one object per group: the group under the dimension's name (or, with no dimension, under the date column's name, labelled at the chart's currentD · W · M · Q · Ygrain), each metric under its resolved name (sum__revenue,count). A KPI never groups and always gets exactly one row. ARAWtemplate gets the filtered rows over the chart'scolumns, in that order, sorted bysortColumndescending, dates asYYYY-MM-DD, at most 500 (MAX_TEMPLATE_ROWS) — a template re-renders on every filter change, and 500 rows of markup is fine where 50 000 is not. - The helpers. Exactly
formatNumberanddateFormat, with the studio preview's semantics; nothing else is registered, so a template that renders in the preview renders in the file. - Escaped output only. The rendered string is written with
innerHTML, which is why the library refuses{{{…}}}and{{& …}}in every template body. - CSS. One
<style>per distinct template, added as text on first use. - When it fails. A template that does not compile or throws on these rows shows "This chart's template could not be rendered: …" in its own card; the rest of the dashboard is unaffected.
- Filtering from a template. A template chart always receives filters. It also sends one when it is an aggregate over a dictionary-encoded dimension and its author marked elements with
data-dg-value="{{…}}": a click on such an element toggles that value on the chart's dimension, exactly like a bar click, and the selected elements get the classdg-selected. The built-intiles-categorydoes this. Apache Superset ignores the attribute.
5a. Looks — a design and its colours, on every target
A look is a design × colours. A design is typography, shape, density, chart drawing style and visual defaults: Financial, Brutal, Luxe. The colours are a theme's palette. You pick both, independently — Financial on a dark palette, Brutal on a light one — and the same pair draws an HTML file, an Apache Superset bundle and a Power BI report. There is one catalogue of designs and one of colours for all three targets. A run without a design is classic: on HTML and Superset the theme's own stylesheet, on Power BI a Power BI theme (Dashboard-Template), each exactly as before designs existed.
Colours (a palette)
Every one of the 40 shipped themes carries fifteen named colours, seeded from <theme> beside its CSS: bg, surface, surface2, text, textMuted, accent, accentDeep, onAccent, border, borderStrong, selected, positive, negative, noticeBg, noticeText. A palette may also say neutral (the warning colour) and eight series colours, series.0 … series.7 — all eight or none. When it says none, the series come from its Apache Superset colour scheme and neutral is a fixed amber, which is exactly what every target drew before; the four palettes the first designs default to (baskerville-navy, slate-corporate, paper-mono, provence-light) say their own series.
A palette may have no stylesheet: colours only. Fourteen ship that way, read off the RASTER Power BI themes (raster-brutal, raster-neon, raster-provence and eleven more). Colours without a stylesheet serve a design on every target and cannot be used alone — a classic HTML or Superset run with one answers 422 theme-not-classic before anything runs. A theme without tokens is the opposite: classic only (422 theme-not-combinable under a design). How a theme gets tokens: Superset themes.
A design
A design is, first of all, a small core that says no colour:
| Part | What it holds |
|---|---|
structure | five switches: radius 0 · 4 · 8 · 12 · 16, border none · hairline · strong, shadow none · soft · hard, chrome light · dark (the header band), density tight · regular · airy |
fonts | three stacks — body, heading, numbers — of installed families, and optionally the face Power BI names per text class (callout, title, header, label) |
chart | the chart drawing style (below), for every chart or split by type: {"*": {...}, "PIE": {...}} |
visuals | the template each chart type draws with by default: chart names Handlebars templates (HTML and Apache Superset), powerbi names Visual-Templates per Power BI visual type |
The core alone is a complete design. Each target draws the look generated from it: an HTML stylesheet, an Apache Superset stylesheet and a Power BI theme fragment, all written against the colour tokens. A design may also carry hand-written bodies — html.css, superset.css, powerbi.json — and a body, where present, replaces the generated one for that target only. The ten shipped designs have hand-written HTML and Superset bodies; Luxe, Financial, Brutal and Provence also have a hand-written Power BI body.
How each target reads it. The HTML file carries the tokens as :root { --dg-bg: …; } and the design's stylesheet scoped under #dg-root.dg-look-<name>; the theme's own CSS is not inlined, so nothing of the theme can fight the design. An Apache Superset bundle carries the design's stylesheet with the palette resolved — literal colours, every declaration !important, the shape the shipped themes have — and the palette's colour scheme. A Power BI report carries a theme composed from a neutral base, the design's fragment and the palette: dataColors are the palette's series, every visual's corners, border, shadow and title band come from the structure, the text classes from the fonts. Visual-Templates still apply on top.
Chart drawing style. CSS cannot reach a chart, so the chart object says it, keys from a closed list; each target applies the keys it can express and ignores the rest:
| Key | HTML | Apache Superset | Power BI |
|---|---|---|---|
smooth | ✓ | ✓ (seriesType) | ✓ (lineChartType) |
showSymbol | ✓ | ✓ (markerEnabled) | ✓ (showMarker) |
lineWidth 1–4 | ✓ | — | ✓ (strokeWidth) |
areaOpacity 0–1 | ✓ | ✓ (opacity) | ✓ (shadeArea) |
pieInnerRadius 0–70 % | ✓ | ✓ (donut) | — (a donut is another visual) |
legend top · bottom · none | ✓ | ✓ | ✓ |
gridLines none · horizontal · both | ✓ | — | ✓ |
axisLine | ✓ | — | — |
barRadius 0–12 | ✓ | — | — |
barGap 10–60 % | ✓ | — | ✓ (categoryInnerPadding) |
labelFontSize 9–14 | ✓ | — | ✓ |
fontFamily | ✓ | — | via the fonts |
gridInset | ✓ | — | — |
An out-of-range value is clamped and logged at seed time, an unknown key refuses the design. Axis and label colours come from the tokens, series from the palette — a design never sets a colour.
Visual defaults. A chart nobody pinned takes the template its design names for its type, when that template resolves and fits; a pin by a person or the agent always wins. On Apache Superset the pin is written into the stored design (the preview shows it), and only where the Handlebars tab is on — the deployment's statement that its Superset renders Handlebars, whose tab note says which two settings it needs. On Power BI the order is: the draft's own choice, else the design's, else the Power BI theme's, else none.
Fonts. The HTML file loads nothing, so a font is either a system stack (free) or a woff2 shipped in a design folder and embedded as a data: URI: SIL Open Font License only, latin subset, at most 80 KB per design, with LICENSE-<family>.txt beside it and a line in PROVENANCE. Power BI draws with the fonts installed on the machine that opens the report, which is why a design names its Power BI faces separately. The ten shipped designs use system stacks and ship no file.
Your own designs
Designs are a template library like the other four (Libraries → Designs): the shipped ones are everyone's and are copied rather than edited; yours are private until you publish them; somebody else's private design is not found; ten per user; taking one private never changes a dashboard that already has it. The editor is controls — three font stacks, the five switches, the chart style, and the visual default per chart type from the templates you may use — with three live previews drawn by the engine from what you have not saved yet. The bodies are under Advanced; empty means generated. Authoring and previewing are free. A design reaches both engines under one name; when the Power BI engine is unreachable it is saved anyway and caught up with on your next visit, and until then a Power BI run with it answers 404 design-not-found.
Adding a design to the platform. A folder <name> with design.json (displayName, description, mood, recommendedThemes, defaultTheme, structure, fonts, visuals, chart) and optionally html.css, superset.css, powerbi.json and fonts/. Both engines seed it at start — insert, update on a changed checksum, never touch enabled — and refuse it, naming the design and the rule, when a body is over 64 KB or contains <, @import, url( or !important, when a Power BI body has a colour literal or an unknown token, when a font is not woff2, over the budget or unlicensed, or when a structure value, a chart key, a visual key or a chart type is outside its list.
Choosing and changing a look
design=<name> beside style= on every generate call, signed in or as a guest, on every target (none is classic). With a design, style names the colours, and on Power BI a blank one is the design's own default. GET /api/v1/dashboards/designs lists the designs, GET …/designs/{name} adds the core and the bodies, and GET …/designs/{name}/preview?target=HTML|SUPERSET|POWER_BI&style=<colours> answers the body the export would write for that pair. All three are open on the guest prefix for the shared designs. Choosing or changing a look runs no agent and costs nothing.
On a finished dashboard: an HTML dashboard takes another look as a free version (§10.3); an Apache Superset dashboard takes one with PUT /api/v1/dashboards/jobs/{id}/look {"style": …, "design": …}, and the next download rebuilds the bundle; a Power BI draft changes its look with PUT /api/v1/drafts/{id}/style {"themeName": …, "design": …} until it is finished.
In the window. The options under the brief — for whichever of the three targets is selected at the top of the window — show the look as one line and one preview with Change, which opens Choose a look: designs down the left, each drawn in its own default colours; colours along the top, grouped as Made for <design>, Light and Dark; one preview of the pair. On Power BI, Classic lists the Power BI themes instead, and the preview is the palette, the four text classes and one card with the design's corners, border, shadow and header — what every visual of the report will share — rather than a mock report. On Apache Superset the preview says that Superset draws its own charts: colours and typography are exact, chart shapes indicative.
5. What a theme has to do (nothing new)
The file uses the exact class vocabulary the 40 seeded Apache Superset themes already style — .dashboard-header, .dashboard-component-chart-holder, .chart-header, .dashboard-chart-title, .chart-container, .slice_container, .filter-box, .ant-tabs-*, .ant-input, .ant-select-selector, .ant-btn*, .ant-card, .ant-tooltip-inner — so no theme author has to do anything for HTML: the same dashboard_style rows, the same style picker, the same gallery serve all three targets. The runtime's own layout lives in runtime.css under a dg- prefix so it never collides with a theme selector. Chart series colours come from the theme's colorScheme, resolved through the same palette table the studio preview uses (supersetPalettes.ts), ported into runtime.js.
6. Pricing (studio, not the engine)
A daily allowance of HTML dashboards is free on every plan, and without an account — the operator's studio.html.free-changes-per-day, three by default; see §10.5. Only a run beyond it is charged, when it starts. The price itself is unchanged: 0.20 € for a DeepSeek run on a file up to studio.html.upload-max-mb (default 50 MB); 0.40 € up to studio.html.large-upload-max-mb (default 100 MB) — a ×2 price multiplier, decided by size alone, no opt-in checkbox. It stacks with the existing extended-description ×2, so a large file with a long brief costs 0.80 €. See licensing/UploadTier.LARGE_X2 and bi_tool_cost.cost_percent (HTML = 80, i.e. 80 % of an Apache Superset run's list price) — V016__html_tool_pricing has the formula and the rationale. GET /api/v1/html/limits serves the two numbers so the upload form can price a file the moment it is chosen.
Without an account it is free, three times a day. A visitor who has not logged in runs HTML against a guest session studio issues: studio.guest.html-runs (default 3) runs per UTC day, one file up to studio.guest.html-upload-max-mb (default 10 MB), the standard description, DeepSeek, public themes. There is no ×2 tier there: GET /api/v1/guest/html/limits reports the same number for both caps and a multiplier of 1, so the form cannot offer a price a guest cannot buy. Nothing is charged and no ledger row is written; the run still lands in generation_cost with plan_type = 'GUEST' so the trial's real cost is visible. The abuse backstops (studio.guest.runs-per-ip-per-day, studio.guest.daily-budget-runs) are what keep this from being a free API. The same session also gets one Power BI dashboard a day, from up to studio.guest.power-bi-max-files (default 5) files within the Power BI embedding budget, and one Apache Superset dashboard a day, from one file up to studio.guest.superset-upload-max-mb (default 10 MB).
7. How to add a chart type to the runtime
HtmlDashboardSpecBuilder.reject/toChart— the validation and metric-shape rules for the newChartType(mirror the existingswitcharms).runtime.jsupdateEchart— a newcasein theswitch (spec.type)building the ECharts option; reuseQ.aggregate,axisStyle(),baseOption(). A type that is not an ECharts chart at all gets its ownkindOfvalue and its ownupdate*function instead —table,pivotandtemplateare the ones that exist.templateis checked first: a pinned library template draws a chart of any type, so a new type needs nothing for it beyondQ.templateRowsgrouping the way its standard rendering does.- If the type should emit a cross-filter, add it to
emits()— and to the editor's list of types that can be Is filtered only (EMITTING_TYPESin the editor's properties pane, andFilterEdits.EMITTERSon the server). HtmlV1BundleExporterTestandHtmlDashboardSpecBuilderTest— a case exercising validation and drop-reason text; a manual browser check (below) for the visual.
Worked example — a chart with two scales. DUAL_AXIS is the one type whose series are not interchangeable, and it shows what step 2 involves beyond a new case:
yAxisbecomes an array of two value axes, and each series names the one it belongs to withyAxisIndex. The right axis switches its split lines off — two grids at two scales read as a drawing mistake — and each axis label takes its first series' colour, which is what pairs curve to axis where a look hides the legend.- The series are built by side, not by
names.map:axisSplitcuts the metric list atdualAxis.leftSeriesand gives each side its shape (BAR,LINE,AREA), so the one-shape- per-metric loop the other types use does not apply. The look's keys land by shape — bar keys on bars, line keys on lines and areas — whichever side they are on. - A type that buckets a date axis must also be added to
isTemporalAxis. Miss it and the chart still draws, but it groups the raw date values as categories and the grain switch never appears — a silent wrong answer rather than an error. - Anything that moves or sums a series along the axis is
query.js's, not the case's. The shift and the running total are two passes insideQ.aggregate, over the buckets in date order and before the values are built:shiftAccsreads bucket i + k's accumulators for the moved measures (and their formula operands) and marks a gap where there is none;cumulateAccsruns sums, counts, min/max and distinct sets forward, so an average and a formula come out of the cumulated parts rather than being summed themselves. The case only names what it did — a moved series carries(+1 month)in the grain's word, its tooltip says which period the value came from, inrenderMode: 'richText'so a column value is never markup — andnode --testcovers the arithmetic without a browser. - The guard at the top of the case (a side empty →
showEmpty) exists because a revision over a new file can leave a chart with fewer metrics than its type needs.
8. Manual browser check
There is no scripted browser test in the CI suite (the requirements' §11 leaves that to a manual acceptance pass). HtmlManualSampleGenerator (no Test suffix, so surefire's default pattern skips it — run it explicitly) builds a realistic ~1500-row dataset, exports it in two real themes, and writes the files to /tmp/html-dashboard-manual-check/ (sales-dashboard.html is the first theme's):
./mvnw -pl generator -am test -DskipFrontend=true \
-Dtest=HtmlManualSampleGenerator#writeSample -Dsurefire.failIfNoSpecifiedTests=false
Serve it (a file:// URL is fine in a real browser; some automation harnesses require http://) and check: KPIs and every chart type render; the date-range and multi-select filters narrow every widget and update location.hash; clicking a bar or a table cell cross-filters (it is listed under Cross-filters in the left panel with its chart's title, the clicked category stays bright, the rest dim, and every other chart's badge counts it); the panel folds to its rail and, in a window narrower than 900 px, becomes the Filters (n) drawer; print preview shows the Filtered: line; "Reset filters" clears everything; the Data tab's table sorts, searches and paginates; "Export CSV" and "Print" run without a console error; every widget's ⋮ downloads a CSV that opens in Excel with umlauts intact and numbers as numbers, and its Enter fullscreen covers the page and leaves on Esc.
The same run writes a <design>-<theme>.html pair for every shipped design beside it — its home theme and one from the other side, such as financial-nordic-dark.html and brutal-provence-light.html. For each: the KPI tiles, tabs, filter bar, chips, table and matrix take the design (Financial: hairlines, small caps, square corners, zebra rows; Brutal: thick borders, offset shadows, boxed tabs, oversized numbers; Editorial: rules, not boxes, and a serif headline; Clinical: 24 px rows and monospace numbers; Neon: lit edges; Paper: ruled cards and dashed rules; Swiss: a thick accent rule on every card; Playful: pills and chunky borders; Luxe: thin serifs and wide tracking); bars have the design's radius and gap, lines its width, the grid its lines, the legend its place; every colour is the theme's — compare the same design's two files and only the colours differ; the header meta says "Design … · Theme …"; the 900 px breakpoint still stacks the cells; print hides what it hides in the classic file; a bar click still highlights with the theme's selected; the Notes tab reads as a document.
Three of the sample's charts are drawn by built-in library templates — Orders by card-metric, Revenue by product by tiles-category, All orders by table-striped-raw — and get their own pass: each template chart renders (not the sentence "This chart's template could not be rendered"); it re-renders when a filter changes; its CSS applies (the cards and tiles have padding and rounded corners, the raw table is striped); a date range outside the data leaves each one in the template's own empty state (an empty card, no tiles, a table with no rows) rather than an error; clicking a tile filters the dashboard by that product, a second click removes the filter, and the selected tile is highlighted; and the console stays clean. Open the file served over http:// or from disk, and keep the tab in the foreground — a background tab pauses requestAnimationFrame, and with it every redraw.
9. Where things are
| Concern | Location |
|---|---|
| Exporter, spec/payload/filter builders, capabilities | v1 |
Shell, base/runtime CSS, query.js, runtime.js | html |
| Vendored ECharts and Handlebars + provenance | vendor |
| Designs: the library, the seeder, the content rules, the chart-style list | design, folders in <name> |
| Theme tokens: vocabulary, rules, the 40 files | {Tokens,TokenRules}, * |
Template charts: rows, rendering, the data-dg-value click | query.js templateRows, runtime.js updateTemplate / listenForTemplateClicks |
| Validating a template pin on a revision | HtmlRevisionService, ChartEditRules |
| HTML design-agent prompt override | design-system.HTML |
| Row-streaming parser methods | * (streamRows) |
query.js's own test suite | query.test (run via node --test or HtmlQueryScriptTest) |
| Exporter/spec/payload/filter/capabilities tests | *Test |
Studio pricing, upload policy, /html/limits | {UploadTier,CoinService}, {HtmlUploadPolicy,HtmlLimitsController} |
| Revisions: lineage, kept source, the async runner, the pruner | revision |
| Revisions: what they cost | {HtmlSettlementService,HtmlDashboard,HtmlDashboardVersion}, V022 |
| The revision bar and its version strip | {HtmlRevisionBar,RevisionStrip} |
| The dashboard editor (the full window, its panes, the widget card) and its state | }, useDashboardEdit, {editState,editDraftStore,chartWells,formula} |
| Hand edits on the engine: data fields, new widgets, calculated measures, the formula parser | {DesignEdits,DesignAdditions}, {HtmlEditorRules,FormulaParser} |
| React tab, upload form, viewer | Html{Tab,UploadForm,DashboardViewer} |
10. Revisions — changing a finished dashboard
A finished HTML dashboard can be rebuilt in another theme, with a chart drawn differently, or from a new instruction — each change producing a new version.
10.1 The model
A dashboard (a lineage) is everything descended from one upload, identified by the root job id. A version is one artifact inside it, and every version is its own generation_job — which is what keeps status, tokens, traces, cost, download and retention working with no new concepts. The lineage lives in two generator-only tables (V015):
| Table | What it holds |
|---|---|
html_source | the kept upload (path, name, expiry), the cached AnalysisResult and the current prompt |
html_revision | one row per version: root_job_id, revision, kind, style_name, user_prompt; since V017 also data_revision (whose upload it reads) and, on a DATA version, its own upload_* |
generation_job gains no column: it is engine-core's and generator-powerbi validates against it, exactly the reasoning V011 gives for keeping the preview in its own table.
10.2 The upload is kept for seven days
PreviewService.record now hands the bytes to HtmlLineageService.registerRoot, which writes them under generator.export-dir/html-sources/{rootJobId}.{ext} with expires_at = now + generator.job-retention. That is a retention change, not an exposure one — the finished .html on the same disk has always held the same rows for the same seven days — but it is stated in the data-privacy notice, on the Instructions page, and can be undone by the user at any time (DELETE /jobs/{id}/source). generator.html.keep-source=false turns the whole feature off.
HtmlSourcePruner (half past every hour) deletes expired rows with their files, and any file no row claims — which is what happens when JobCleanupScheduler takes the job row first. That is why html_source deliberately has no foreign key.
10.3 What a change set may say
POST /api/v1/dashboards/jobs/{id}/revisions, one request per version:
{ "style": "midnight",
"design": "financial",
"chartEdits": [ { "chartId": "c2", "type": "LINE" },
{ "chartId": "c3", "templateName": "tiles-category" },
{ "chartId": "c4", "title": "Umsatz nach Region",
"columns": ["region", "revenue"], "sortColumn": "revenue",
"sortAscending": true, "showTotals": true },
{ "chartId": "c7", "remove": true } ],
"name": "Lager München",
"layout": { "...": "the whole grid, tabs down to cells — see below" },
"userPrompt": "Focus on margin by region",
"enrich": "plz-de",
"llmProvider": "DEEPSEEK" }
userPromptpresent ⇒REFINE: the design agent runs again over the stored analysis (analysis_keyhashes the schema, so a changed upload re-derives it) and the prompt replaces the lineage's from then on.enrichneeds an instruction. Which reference tables are joined decides which columns the design may use, so changing it without auserPromptis422 enrich-needs-instruction: the dashboard has to be designed again around the new vocabulary. Left out, a version reads exactly the columns the version it derives from read — its own plan is recorded on its row, so a rebuild months later is the same dashboard even after the server's tables have been refreshed, and a table that has since been removed leaves its columns empty rather than failing the rebuild. See the reference tables page.- Otherwise ⇒
RESTYLE: no agent, no token,llm_calls = 0. That includesdesign: absent keeps the current version's design, the stringnonemakes a classic version, a name is resolved (404 design-not-foundwhen unknown or disabled) and checked against the theme's tokens (422 theme-not-combinable). Each version records its design beside its theme, and restoring a designed version rebuilds it designed, whatever the design'senabledsays now. - The same URL as
multipart/form-data, with afilepart ⇒DATA(T11): the current version's design drawn from the new file's rows — next month's export into last month's dashboard. No agent. An optionalrequestJSON part may change the theme or chart types in the same version; auserPromptbeside new data is refused as422 data-with-instruction, because that is a second change priced differently. Before anything is stored the design is checked against the new columns with the exporter's own chart validation (html/v1/HtmlDesignCheck): a chart whose columns are gone is left out and counted in the version'schartsDropped; a file from which not one chart can be drawn answers422 columns-changedwithmissingColumns. OptionalsheetandheaderRowparameters as on a generation — and a rebuild reads the header row it was first read under (source_header_row, generatorV018, T13). - A hand edit ⇒
EDIT(ED-T4):name,layout, a chart's property set (title,columns,sortColumn+sortAscending,showTotals) or aremove. No agent, free like a restyle, counted against the samerestyles-per-day. EachchartEditsentry names exactly one thing: atype/templateNameswitch (as before), a property set, orremovealone.layoutsends the grid whole — every id must belong to the dashboard and appear at most once, widths 1–12, heights 10–120 (422 layout-rejectednames the miss); a chart the grid leaves out is still placed by the exporter, so leaving one out is never a removal, and the last chart cannot be removed. A hand edit beside auserPromptis422 edit-with-instruction— the agent designs its own grid, which would overwrite the edit it travelled with.sortAscendingwithout asortColumnflips the chart's default order (an aggregate chart sorts by its first metric). - What a widget computes is a hand edit too (DE-R3): a
chartEditsentry may carrymetricandadditionalMetrics(measures by the name the dashboard's design answer offers them under —sum__amount,count,count_distinct__customer_no,calc__cost_per_hour), adimension(group-by field), atemporalColumn(time axis), acolumnDimension(a matrix's columns), andclearDimension/clearTemporalColumnto take a field off. Atypemay travel in the same entry: the fields are applied first, then the type is checked against them, so "make this bar a line over posting_date" is one edit. A template switch still travels alone. Every field is checked the way the export checks it, and an edit that would make the export drop the chart is refused (422 chart-edit-rejected, This chart would not be drawn: no date column.) — a hand edit never loses a chart silently. A sort by a measure the chart no longer draws falls back to the default order. A two-axes chart's entry may carrydualAxis—{ "leftShape": "LINE", "rightShape": "LINE", "leftSeries": 1, "shift": 1, "cumulative": false }, replaced whole, refused on any other type (Only a two-axes chart has axis options) and cleared by atypeswitch away; the shapes areBAR/LINE/AREA, a side holds one to three measures, the shift is −12…12 periods. A chart with a time axis (line, area, bar, scatter, two axes) may carrytimeGrainas the grain it starts on; the file's D W M Q Y switch still changes it. - A table's or a matrix's options (TB-R6):
timeGrain(DAY…YEAR,""clears it) for a widget grouped by a date,cellBars(true/false), andformats— the whole list of colour rules, each{ "column", "kind": "RULE" | "SCALE", "operator", "values", "colour", "target": "CELL" | "ROW", "scale": "ONE" | "DIVERGING", "midpoint" }. A rule names a column the widget shows, by the name the design answer offers; a number rule takes numbers, a text rule text;colourisGOOD,BAD,WARNINGorACCENT; only a table colours a row; at most ten.sortColumnmay name the group field itself; a field the widget does not group by is refused (422 chart-edit-rejected) rather than silently ignored. Another type, or a data edit that takes the date or a column away, takes the step and that column's rules with it.addChartstakes the same three. addChartsadds widgets (DE-R4):{ "clientId": "new-1", "type": "KPI", "metric": "count", "title": "Orders" }— the same fields, checked the same way. The server gives each an id derived from itsclientId(the same staged state always names it alike), answers the mapping in the dry-run'schartIds, and letslayoutin the same request place it by itsclientId. Without a place it goes to a row of its own at the end of the tab named byactiveTab(else the first). At most 40 widgets per dashboard.calculateddefines calculated measures (DE-R5):{ "clientId": "new-measure-1", "label": "Cost per hour", "formula": "{sum__cost} / {sum__hours}", "decimals": 2 }adds one,{ "name": "calc__cost_per_hour", … }changes it,{ "name": …, "remove": true }removes it (refused while a chart still reads it). A formula is measures in braces, numbers,+ - * /and parentheses — at most ten measures, 200 characters, and no calculated measure inside another; a wrong one is422 formula-invalidwith thepositionandtoken. The new measure is namedcalc__+ a slug of its label; a chart edit or a new widget of the same request may use it by itsclientIdalready, and the dry-run answers the name inmeasureNames. See Calculated measures below for how the file computes them.logois part of the same hand edit (ED-T9): a PNG, JPEG or WEBP as adata:image/…;base64,…URI, at most 96 KB decoded, drawn left of the title by the runtime (spec.branding.logo, rendered as an image attribute — the CSP'simg-src data:is exactly what it needs). The bytes are sniffed, never trusted: a mislabeled file is stored as what it is, an SVG is refused outright (422 logo-rejected) because it is a document that can carry scripts. Absent keeps the version's own logo,""takes it off; each version records its logo (html_revision.logo,V024) so restore brings it back, and a blueprint snapshots it so the ERP's instances carry it.- Chart edits are validated by
ChartEditRulesbefore anything is created, all or nothing. Each edit names exactly one oftypeortemplateName(both, or neither, is422 chart-edit-rejected). AtemplateNamepins a library template (§4, Template charts): it is resolved as the caller may see it — an unknown, disabled, or somebody else's private template is404 chart-template-not-found, and a visitor without an account may pin only public ones — and it must be built for the chart's own type and, ifRAW, meet a chart with a column list; either miss is422 chart-edit-rejectedin the words the picker shows. A pin runs no agent, so it is aRESTYLE. A latertypeedit on the same chart unpins it. - The preview's own
POST /jobs/{id}/charts/{chartId}stays Apache Superset's. For an HTML job it answers 422 naming/revisions— an artifact that cannot be rebuilt in place must not be marked stale.
Everything else: GET …/revisions lists the versions, POST …/revisions/{n}/restore brings an older one back as a new version, and DELETE …/source ends the lineage's editable life.
10.4 Only the newest version keeps a file
Creating a version deletes the superseded one's .html and nulls its export_file_path; asking for that version again rebuilds it from its own stored design and hands it over without keeping it. This rests on the export being byte-deterministic, which HtmlV1BundleExporterTest.twoExportsOfTheSameDesignAreIdentical pins for both the inline and the gzipped payload.
Each version reads its own data (T11, V017). A DATA version keeps the file it brought under generator.export-dir/html-revision-data/, named by a fresh id; every version records in data_revision whose upload it reads (null: the lineage's html_source). So an older version is rebuilt from the rows it was drawn from, and a restore brings back the target's data with its design. Delete my file removes all of a lineage's files, not only the first; HtmlSourcePruner sweeps a data file no version claims (its version went with its job) once it is an hour old.
10.5 What it costs (studio, V022)
A daily allowance of free agent runs on every plan (studio.html.free-changes-per-day, three by default), and what they build downloads free; one price per run after that.
| Event | What happens |
|---|---|
| Generate (async or sync) | free while studio.html.free-changes-per-day (3) lasts; the 4th run of the day charges one HTML price at once, which settles the dashboard |
| Refine | the same rule — it is an agent run |
| Restyle / restore / hand edit (EDIT) | nothing, ever; bounded by studio.html.restyles-per-day (20) to cap engine work |
| Replace data | nothing, ever — no agent; counted against the same restyles-per-day (a guest: studio.guest.restyles-per-session), and the HTML upload caps of a run apply |
| Download of a dashboard built on a free run | free — the root version's free_run says so |
| First download of an unpaid dashboard | one HTML price, at the dashboard's own tiers. Only reachable when the charge that settled it was refunded |
| Later downloads, any version | free, for ever |
html_dashboard (keyed by root job) records the tiers, the settling transaction and settled_by_job_id — the version whose charge paid, and so the only one whose failure may take the paid mark back (V026; a download's settlement names no job, so no refund removes it); html_dashboard_version maps every version to its dashboard and remembers whether it took a free change, so a failed version gives back exactly what it took. A dashboard with no html_dashboard row predates this change and was already paid for at generation — that is why settlement keys off the presence of a row rather than a flag.
GET /api/v1/dashboards/jobs/{id}/price answers {settled, centicoins, chargesCoins} so the download button can say the price before it is pressed rather than after.
10.6 Without an account
A guest revises only the dashboards its own session started. A refine spends one of the session's three runs; a restyle spends nothing and is bounded by studio.guest.restyles-per-session (10). Guest downloads stay free, and no guest dashboard is ever settled. The three new routes are declared in UnauthenticatedSurfaceTest — adding one without declaring it fails the build.
10.7 In the window
HtmlRevisionBar renders under the artifact for an HTML result. Signed in, it offers Edit dashboard first — the full-window editor of §10.9, where the look, the layout and every widget are changed — and keeps Instruction and Data beside it, one Apply button (so staged changes are one version), a version strip, and Delete my file. Without an account the bar is the whole of it: Look, Charts, Instruction, Data and the same Apply.
Look opens the same Choose a look dialog as the form (§5a). While it is open, every highlighted look is drawn on the dashboard itself behind the dialog — the window posts dg:try-look into the frame (§4) and the file redraws in it, with no request and no version. Choosing one makes the free version at once, together with any chart changes already staged; beside a staged instruction or a newer file it is staged instead and goes with Rebuild/Apply, because those change what the click costs. Cancel puts the dashboard's own look back. A file built before the try-on existed does not answer within 300 ms; the dialog then says Preview after rebuild and choosing still builds the version. The button's label is the whole price story — Apply — free, Rebuild — free (2 of today's changes left), or Rebuild — 0.2 Coins. While a version builds the bar stays on screen, disabled, and the frame keeps showing the version before it.
A chart can also be changed where it is (T12): click its title on the dashboard. Signed in, that opens the editor on the widget's card (§10.9). Without an account it opens a type picker: the runtime's message (§4) reaches HtmlDashboardViewer, which accepts it only when event.source is its own frame — the frame's origin is opaque, so the source window is the only thing that identifies it. ResultPane holds the chart id; HtmlRevisionBar opens ChartTypePicker in its typesOnly mode (no Handlebars: the HTML file does not carry that plugin) and sends one free version with the picked type plus whatever look and chart changes were already staged, never a staged instruction or data. A type ChartEditRules refuses keeps the picker open with the engine's 422 sentence.
10.8 Sharing
A finished HTML dashboard can be opened by anybody who has its link, without an account. Nothing is shared until the owner presses Share beside Download and then Create link; the dialog says what a link gives away before it exists.
| Route | Who | What |
|---|---|---|
GET / POST / DELETE /api/v1/dashboards/jobs/{id}/share | the owner | read (204 when none), create, stop |
the same under /api/v1/guest | the session that built it | the same |
GET /d/{token} | anybody with the token | the dashboard's current finished version |
- Ownership is the
html_dashboardrow (V022): itsuser_idorguest_id, reached through whichever version was named. Somebody else's dashboard, and one studio never tracked, is404. - One live link per dashboard. A second
POSTanswers the first. Live links are capped bystudio.html.shares-per-user(20) andstudio.html.shares-per-guest(3); stopped and expired ones do not count. Past the cap:429 share-limitwithlimit. - Expiry is the shown version's build time plus
studio.html.share-retention, which reads the sameGENERATOR_JOB_RETENTIONas the generator: the generator deletes a job that long after it was created, and a link cannot outlive the file it streams. A dashboard whose file is already gone answers409 dashboard-not-shareable. - Always the current version.
GET /d/{token}asks the generator for the lineage on every request, so a theme changed after sharing is what the reader sees. - The headers are the feature.
Content-Disposition: inline,Cache-Control: no-cache, private(a stopped link stops on the next reload),X-Robots-Tag: noindex, nofollow,Referrer-Policy: no-referrer,X-Frame-Options: SAMEORIGIN(every other path keepsDENY), andContent-Security-Policy: sandbox allow-scripts allow-downloads allow-modals allow-popups allow-popups-to-escape-sandbox. That last one is not optional: the file is generated from somebody's data and served from the site's own origin, and sandboxed it runs with an opaque origin instead — exactly the iframe the window already shows it in. - A dead link is a page, not JSON. Unknown, stopped, expired, or its file swept: a small
404HTML page telling the reader to ask for a new link. - No agent runs and nothing is charged.
/d/{token}is inANONYMOUS_PAGESand the guest route inANONYMOUS_API, both on purpose. Stored inhtml_share(V028).
10.9 Editing a dashboard
The AI makes the first version. From then on the owner edits by hand, the way a Superset dashboard is edited: Edit dashboard — on the result's header, first on the revision bar, or by clicking a widget's title — opens the dashboard editor over the whole window. It is the HTML target's own; Apache Superset and Power BI keep theirs. Back in the browser closes it; the address carries ?edit=<job>, so a reload opens it again on the same dashboard.
Its layout. A bar on top: the dashboard's name (typed in place), the versions (AI marks the ones the AI built, ✎ the ones edited by hand), Undo and Redo (Ctrl+Z, Ctrl+Shift+Z), Discard, how many changes are staged, Look, Ask the AI and Save version — free. On the left, + Widget (the chart types, each opening a new widget's card), + Tab, the dashboard as an outline — every tab with its widgets, in order — its calculated measures and its filters. In the middle, the dashboard file itself, in edit mode. On the right, the selected widget's properties, or, with nothing selected, the dashboard's name and logo. Both side panes fold away; below 900 pixels wide the editor asks for a wider window.
On the dashboard itself.
- Move: drag a widget by its header. A blue line shows where it lands — before or after another widget, or a row of its own between two rows — and dropping it on a tab moves it there.
- Resize: pull the right edge for the width, which snaps to the twelve-column grid while a guide shows the columns; pull the bottom edge for the height; pull the ◢ corner for both. The grips appear on the widget under the pointer and on the selected one.
- Select: click a widget's body. Its properties show on the right; Delete removes it, the arrow keys move it in the grid, Escape lets it go.
- Edit: click its title, or ✎ — the widget's card opens (below). ⧉ duplicates it beside itself, 🗑 removes it. The last widget cannot be removed.
- Tabs: the tab bar is always shown while editing. + adds a tab, a double-click renames one in place, its ⋯ menu moves it left or right or deletes it once it is empty. A new tab shows Drop a widget here until something is in it; an empty tab is never saved.
In edit mode a click on a bar or a slice selects the widget instead of filtering the dashboard.
Every one of these is also in the panes — the outline for order and tabs (drag there too), the properties for width, height, place and tab as steppers — so the editor works from the keyboard.
The widget's card has three columns, like Superset's chart view:
- the dataset — every column with its type (# number, A text, 📅 date, ✓ yes/no; a column a reference table added is marked), and the measures: COUNT(*) and the calculated ones;
- the chart — its type (a gallery of the eleven), and wells for what the type draws: Measures, Group by, Time axis, a table's Columns, a matrix's Rows and Columns, a two-axes chart's Left axis and Right axis (one to three measures each, a shape — bars, line, area — beside each label; a chip dragged across swaps the side). A field is dragged from the dataset onto a well — only the wells that take it light up — or added from the well's + list; a chip is dragged within its well to reorder the series and between wells to move it, and × takes it off. A column dropped on a measure well becomes its sum (a number) or its distinct count (text). A measure chip opens a popover: Simple is a field and an aggregation, Formula picks or writes a calculated measure. Switching the type keeps every chip that fits; a well the new type still needs turns red. Below: the sort, totals, and the title, with a one-click suggestion such as SUM(amount) by region;
- the chart itself, drawn by the dashboard file's own runtime from the engine's dry-run, alone and full size. What it shows is what the file will draw; a chart the export would leave out is said so in the export's words, and Save waits.
Everything works without dragging, too: a dataset field is added with Enter where it fits, the + lists add, and a chip's ⋯ menu moves it to another well.
Options on the card: a library template to draw the widget with — the templates built for its type that the account may use; a template reads the fields above, and another type takes it off; a new widget is added first and pinned after — and, on a card, a table or a matrix with a date, Today only, which keeps the widget on the latest day the data holds. A two-axes chart adds Shift right axis by — a number of periods of the chart's grain, +1 drawing next period's right-axis value at this one so a cause and its delayed effect line up, the moved series named (+1 month) and its tooltip saying which period a value came from — and Running total, every series summed from the first period shown. Any chart with a time axis has Starts on, the grain the file opens it at. The quickest two-axes chart is a line with two measures and a date: open its card, pick Two axes — the first measure goes left, the rest right.
A table's or a matrix's card adds Group dates by (day to year; a date newly grouped starts at month), a Sort by that offers the group field itself — Oldest first / Newest first for a date, A → Z / Z → A for text, Smallest / Largest first for a number — and Formatting: Cell bars (on for a new table) and the colour rules, each added, changed, moved up or down or removed in a small form. A change that leaves a rule without its column says which rules it took.
Calculated measures are formulas over measures, worked out for every group after the rows are summed up: Cost per hour = SUM(cost) ÷ SUM(hours) is each employee's total cost divided by their total hours on a bar, the overall ratio on a card, and changes with every filter and cross-filter. It is never the average of per-row rates. A zero divisor shows —; an "Other" slice of a pie recomputes the formula from its summed operands when they add up, and shows nothing when they do not. A calculated measure cannot be drawn as a pie or a funnel slice — a share of a ratio means nothing — and may sort a chart like any measure. It may carry a number of decimals or show as a percentage. In the file it travels as data, never as code: the formula is checked and compiled on the server, and the browser only walks the compiled form.
Filters are chosen in the outline's Filters section, the way Superset's filter bar is configured. Until one is changed the section shows the panel the file derived and says Chosen automatically; the first change makes the panel the dashboard's own, and Reset to automatic hands it back. + Filter adds one from a column — values for text, a date range for a date (one per dashboard), a number range for a number; at most twelve. Selecting a filter — in the outline, or by its label in the dashboard's own panel — opens its properties on the right: its label, what it is set to when the dashboard opens (values ticked from the rows on screen; a quick range or two dates; two numbers) and Applies to — every widget, or only the ones ticked. A widget's properties add Cross-filter: Filters other widgets and is filtered, Is filtered only, or Ignores cross-filters. In edit mode the panel on the canvas is drawn but its controls do nothing; a filter's label selects it. A version from a new instruction keeps the filters and the widgets' settings — the AI is told nothing about them — and drops a filter whose column is gone.
Every staged change is drawn on the dashboard itself before anything is rebuilt: POST /api/v1/dashboards/jobs/{id}/edit-preview takes the same change set /revisions takes, applies it in memory and answers with the spec the rebuilt file would carry plus what the build would drop and why — chart by chart, with its id — and the ids and names it gave what was added. The window posts that spec into the frame (dg:try-design, with focus naming one chart for the card; the tabs staged empty are put back in so they show) and the file redraws. No job, no agent, no charge. The file draws the editing handles itself when the window sends dg:edit-mode, and everything done on it comes back as an intent (§4); a version built before the handles existed answers without them, and the editor then says to use the panes.
The look chosen in the editor (design and colours, §5a) is tried on the dashboard at once and staged with the rest; Undo takes it back like any other change.
Save version sends everything staged — layout, tabs, widgets, measures, filters, name, logo and the look — as one revision of kind EDIT: deterministic, free, counted against restyles-per-day. The editor stays open and shows the new version when it is built.
Ask the AI rebuilds the dashboard from a new instruction, at the price of an agent run. The AI designs its own layout, so it is offered only with nothing staged — save or discard first (§10.3, edit-with-instruction). The new version opens in the editor, ready for hand editing; the hand-tuned one stays in the strip, one restore away.
Unsaved changes are kept in the browser, per dashboard, until they are saved or discarded: a reload, Back or a closed tab gives them back with a note. Leaving with the editor's own Back, or picking another version, asks first: Save version, Discard or Stay.
A situational blueprint is edited the same way: Edit on its card opens the editor on the dashboard drawn on invented test data, a band above the canvas says so and draws it on the contract's example rows or the last delivery on request, and Publish to the blueprint — once the version is saved — replaces the blueprint after showing what changes in the contract. The ERP's next delivery draws the new form on its own rows — see the situational dashboards page.
DASH:Gen