DASH:Gen

Docs

HTML dashboard — a third target next to Apache Superset and Power BI

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

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.*:

PropertyDefaultWhat it bounds
generator.html.max-rows1 000 000rows read from the upload, in file order
generator.html.max-payload-mb40the gzipped payload; over it, rows are shed in 10 % steps until it fits
generator.html.inline-payload-max-kb1024the 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:

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.

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:

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:

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.

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:

PartWhat it holds
structurefive 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
fontsthree stacks — body, heading, numbers — of installed families, and optionally the face Power BI names per text class (callout, title, header, label)
chartthe chart drawing style (below), for every chart or split by type: {"*": {...}, "PIE": {...}}
visualsthe 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:

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

  1. HtmlDashboardSpecBuilder.reject/toChart — the validation and metric-shape rules for the new ChartType (mirror the existing switch arms).
  2. runtime.js updateEchart — a new case in the switch (spec.type) building the ECharts option; reuse Q.aggregate, axisStyle(), baseOption(). A type that is not an ECharts chart at all gets its own kindOf value and its own update* function instead — table, pivot and template are the ones that exist. template is checked first: a pinned library template draws a chart of any type, so a new type needs nothing for it beyond Q.templateRows grouping the way its standard rendering does.
  3. 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_TYPES in the editor's properties pane, and FilterEdits.EMITTERS on the server).
  4. HtmlV1BundleExporterTest and HtmlDashboardSpecBuilderTest — 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:

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

ConcernLocation
Exporter, spec/payload/filter builders, capabilitiesv1
Shell, base/runtime CSS, query.js, runtime.jshtml
Vendored ECharts and Handlebars + provenancevendor
Designs: the library, the seeder, the content rules, the chart-style listdesign, folders in <name>
Theme tokens: vocabulary, rules, the 40 files{Tokens,TokenRules}, *
Template charts: rows, rendering, the data-dg-value clickquery.js templateRows, runtime.js updateTemplate / listenForTemplateClicks
Validating a template pin on a revisionHtmlRevisionService, ChartEditRules
HTML design-agent prompt overridedesign-system.HTML
Row-streaming parser methods* (streamRows)
query.js's own test suitequery.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 prunerrevision
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, viewerHtml{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):

TableWhat it holds
html_sourcethe kept upload (path, name, expiry), the cached AnalysisResult and the current prompt
html_revisionone 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" }

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.

EventWhat 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
Refinethe 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 datanothing, 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 runfree — the root version's free_run says so
First download of an unpaid dashboardone HTML price, at the dashboard's own tiers. Only reachable when the charge that settled it was refunded
Later downloads, any versionfree, 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.

RouteWhoWhat
GET / POST / DELETE /api/v1/dashboards/jobs/{id}/sharethe ownerread (204 when none), create, stop
the same under /api/v1/guestthe session that built itthe same
GET /d/{token}anybody with the tokenthe dashboard's current finished version

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.

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:

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.