One system for both tiers: the read-only report (dstf run --report, the
dashboard) and the authoring tool (dstf author). Every value here is lifted
from what the report already ships in
src/dstf/reporting/templates/dashboard.html / interactive.html and from
src/dstf/figures/plotly.py, except where a line says corrected. The
report defines the language; the tool renders it through NiceGUI, which is
Quasar (Material Design components) plus Tailwind utilities. So the tool
chooses among Quasar variants and states. It never invents a control.
Status: the owner chose this Material chrome over a neutral alternate on the design canvas on 2026-09-15 (brief §3.9). It is the default theme.
Every neutral, the accent and the status tints are CSS variables named as in
the tables below, on :root in the shared stylesheet and mirrored in the
tool's Quasar palette. A theme is a NAME of a bundled table that sets the
sixteen tokens below (reporting/theme.py); there are two, default — this
document's values — and dark (D148). No theme file, no inline colours.
What a theme may change is what sits BEHIND meaning. The status fills, the
filled badge (white on the fill), the baseline-role chips and the plot series
keep their values in every theme, with two exceptions on dark, both because
the light value does not read there:
- A status's tint, pill, pill border and the text on them are DIMMED: the
fill laid at 14 % (tint), 24 % (pill) and 60 % (border) over the dark
surface, and the status's pastel — the light table's pill border; for
warnings the lighter amber
#ffd54f— as the text. The light tints would be light islands on a dark page. - Eight plot colours are lightened ("Plot series" below): five under 2.2:1 on the dark plot, and three a person reads against other series, held to 4.5:1 there — the actual's event line, the sibling-backend overlay and the companion overlay. No other series colour moves.
Each theme has one plot layout template in the figure layer
(figures/palette.py): the plot's ground, grid, zero line, axis lines, ticks
and titles, legend, hover label and toolbar. default has none — its plot is
plotly.js's own. The figure's chrome follows the theme too: a reference
event's guide line, an open criterion's ring and handle markers, the grey of a
context series, a marker's outline and the tint of a region drawn over the
plot's whole height.
The theme is chosen in testing.json ("theme"): the dashboard and the
report pages are generated in it, with no switch in those pages, and the tool
opens in it. In the tool it can be switched per browser tab, for the session,
from the header's ⋮.
Tokens (neutrals, accent, selection)
| Token | default | dark | Use |
|---|---|---|---|
| page | #fafafa |
#121212 |
page background, sticky chrome |
| surface | #ffffff |
#1e1e1e |
cards, tables, plots, panels |
| surface-hover | #f5f5f5 |
#2a2a2a |
hover on rows and counters; table headers |
| surface-active | #ececec |
#353535 |
hover on a control that rests on surface-hover |
| border | #dddddd |
#484848 |
cards, tables, chrome dividers |
| border-hairline | #eeeeee |
#303030 |
inside cards; plot frames; the dark plot's grid |
| text | #333333 |
#e4e4e4 |
body |
| text-secondary | #666666 |
#b8b8b8 |
status bar, detail cells, hints |
| text-muted | #888888 |
#9e9e9e |
labels, unscored, queued; a context series |
| text-disabled | #999999 |
#7d7d7d |
reference values |
| accent | #1976D2 |
#64b5f6 |
links, sort markers, selection ring, the primary button |
| accent-hover | #1565C0 |
#90caf9 |
the accent, hovered |
| selection | #e3f2fd |
#1d3a57 |
the selection tint |
| shadow | rgba(0,0,0,0.1) |
rgba(0,0,0,0.5) |
elevation |
| shadow-menu | rgba(0,0,0,0.15) |
rgba(0,0,0,0.6) |
under a floating menu that lies over content (the Suite table's column chooser) |
| on-accent | #ffffff |
#0b1a2b |
text on a FILLED accent (the primary button) |
One accent only; never a second. The dark neutrals are neutral grey, not
blue-tinted, and the dark page is Quasar's own dark page. on-accent is a
token of its own because on a dark surface no one blue both reads as link
text and carries white text: white on the dark accent is 2.2:1.
Status. The words are the classifier's (reporting/status.py), the colours the
dashboard's. Each status has a fill (filled badge, bar segment), a dark
text colour, and a tint row background.
| Status | fill | dark | tint | border (tinted pill) |
|---|---|---|---|---|
| pass | #4CAF50 |
#2E7D32 |
#f1f8e9 (rows) / #e8f5e9 (pill) |
#a5d6a7 |
| fail | #f44336 |
#C62828 |
#ffebee |
#ef9a9a |
| sim-fail | #d32f2f |
#B71C1C |
#ffebee |
#ef9a9a |
| no-ref | #FF9800 |
#E65100 |
#fff8e1 |
#ffe0b2 |
| warnings / advisory | #ffc107 |
#8a6d00 |
#fff3cd |
#ffc107 |
| timed-out | #9C27B0 |
#6A1B9A |
#f3e5f5 |
#e1bee7 |
| queued / not-run / scoring | #888888 |
#555555 |
#f5f5f5 |
#dddddd |
| running | #2196F3 |
#1976D2 |
#e3f2fd |
#90caf9 |
| pending / info | #607D8B |
#607D8B |
#eceff1 |
#b0bec5 |
The tinted pill's border is the fill's Material 200 shade; the dashboard
ships only pass and fail, the rest are derived the same way. These are the
default values; on dark the fill column is the same and the other three
are dimmed as "Themes" says (pass: tint #243225, pill #29412a, text
#a5d6a7).
Baseline roles (chips in the overlay picker): soft_check #e8d8f3 on
#5e3a80, border #c4a8df; companion #d8f3e4 on #2b6b48, border
#a8dfc0.
Plot series (the figure layer; literal hex in figures, never CSS variables)
| Series | Value |
|---|---|
| actual | #2196F3, solid |
| reference | #FF9800, dashed |
| pass region (tube, box) | fill rgba(76,175,80,0.15), line rgba(76,175,80,0.6) |
| fail region | fill rgba(244,67,54,0.15), line rgba(244,67,54,0.8) |
| window | fill rgba(33,150,243,0.08), line rgba(33,150,243,0.4) |
| error axis and traces | #f44336 |
| muted / out-of-window | rgba(117,117,117,0.35) |
| drag handle | ring #2e7d32, fill rgba(46,125,50,0.25) |
| overlays | soft check #7B1FA2 dot · sibling backend #1976D2 dash · companion #388E3C dashdot |
| live (running) trace | actual colour; the unfinished end carries a marker in #2196F3 |
On dark (figures/palette.py; contrast on the dark plot #1e1e1e):
| What | default | dark |
|---|---|---|
| soft-check overlay (the dotted line above) | #7B1FA2 (2.0) |
#ba68c8 (4.7) |
| sibling-backend overlay (the dashed line above), the actual's event line | #1976D2 (3.6) |
#64b5f6 (7.5) |
| companion overlay (the dash-dot line above) | #388E3C (4.0) |
#81c784 (8.3) |
| blue ramp, darkest stop | #0d47a1 (1.9) |
#1264e2 (3.1) |
| green ramp, darkest stop | #1b5e20 (2.1) |
#237b2a (3.1) |
| purple ramp, third stop | #7b1fa2 (2.0) |
#a631d8 (3.2) |
| purple ramp, darkest stop | #4a148c (1.4) |
#8a40e4 (3.1) |
| drag handle (chrome) | ring #2e7d32 |
ring #66bb6a, fill rgba(102,187,106,0.25) |
| reference event line (chrome) | #757575 |
#a8a8a8 |
| context series (chrome) | #888888 |
#9e9e9e — the theme's text-muted |
| marker outline (chrome) | white |
#1e1e1e — the plot's own ground |
| whole-height region tint (chrome) | rgba(117,117,117,0.09) |
rgba(158,158,158,0.09) |
A ramp stop is lightened at its own hue and saturation until it clears 3:1 and still stands darker than the stop before it; the eight other stops are the same in both themes.
Stack, corrected: "Segoe UI", Roboto, system-ui, -apple-system, sans-serif. The shipped stack ends in monospace with no generic
sans-serif, so a Linux browser without Segoe UI or Roboto renders the report
in monospace. Plotly text stays Plotly's default (Open Sans, 12px).
Mono, for values, paths and model IDs where alignment matters:
ui-monospace, Consolas, "Cascadia Mono", monospace.
| Role | Size / weight | Notes |
|---|---|---|
| page title (h1) | 20px / 600 | "DSTF" wordmark is this size, weight 700 |
| section (h2) | 16px / 600 | #555, hairline below |
| body, controls | 14px / 400 | Quasar's base |
| table cells, detail | 12px / 400 | the dashboard's 0.85em |
| labels, captions | 10px / 400 | uppercase, #888, letter-spacing 0.03em |
| status badge | 10.5px / 700 | uppercase, letter-spacing 0.03em |
| stat value | 18px / 600 | counters and key stats |
| numbers in tables | mono 12px | right-aligned |
Sentence case everywhere: "Save 3 spec edits", never "SAVE". Every Quasar
button carries no-caps.
- Page padding 24px. Card padding 16px. Table cell padding 4px 8px (5px 8px in the per-test report).
- Gaps: 8px inside a control group, 12px between groups, 16px between cards, 24px between page sections.
- Radius: 4px cards, counters, panels; 3px badges and buttons; 2px inputs.
- Borders, not shadows: 1px
borderon every surface that sits in the page. Surfaces that float over it (menus, popovers, dialogs, the fixed action footer) carry one shadow,0 2px 8px rgba(0,0,0,0.1), which is also what Quasar's menu and dialog ship with. - Counters keep their 4px left border in the status fill. That is the dashboard's existing signature and stays limited to counters.
- Control heights: dense everywhere. Buttons 28px, inputs and selects 32px, table rows 28px, test-list rows 32px. Plots 320px tall, full width.
- Motion: 0.1s background transitions on hover. Nothing else animates except the running progress bar.
Buttons. Three tiers, each a Quasar variant. Filled primary
(unelevated color=primary): the one action the page exists for, at most
one per header, usually Save. Outlined secondary (outline): actions on a
selection (Queue, Accept). Flat tertiary (flat): navigation and
housekeeping (Discard; the header's ⋮ menu holds Export, Change backend…,
the dashboard and Stop server as menu items). Destructive actions are outlined in fail and
confirm.
A button's label names its object, its effect and a count: "Save 3 spec edits (2 tests) to test_spec.json" — "(2 tests, 1 new entry)" when a save creates an entry — "Queue 3 (2 already queued)", "Accept 2 results as baselines". A tooltip says when to use it. A count of zero disables the button and the label still says what it would do. In a ⋮ menu, where a reason has no place beside the item, a disabled item reads the action's plain name over its reason instead ("Queue" / "FakeLib.A is already running"), never a caption that counts zero.
Disabled states explain themselves. A greyed control keeps its label and gets one line beside or below it: why it is unavailable and what enables it ("Queue needs a selection", "FakeLib.A is running: accept it when its run has ended"). Never a bare grey button.
Status badges. Two forms with two meanings. The filled badge (white text on the status fill) is a test's verdict: test list, header, queue. The tinted pill (dark text on the tint, 1px border) is a variable's or a criterion's verdict, inside a test. An advisory (warn-wrapped) failure is the amber chip, never red.
A pill says which variable it is about only when the card cannot: a plot of one series has that variable in its title, so its pill is the bare status, while a plot of several carries one pill per tracked series reading "x FAIL" — the name in the case the test wrote it in, not the pill's uppercase.
The criteria tree's closed line says the same thing once each: the root's
pill, what the root is (and · 4 criteria), the tally (3 pass · 1 fail,
plus an advisory and an unscored count when either is non-zero), then each
failure by name as the button that jumps to its criterion — at most two, with
"+k more" for the rest. The tally does not repeat the names, and a name
appears once.
Tables for homogeneous rows the user sorts and filters: the test list, the queue, criteria points. Sticky header, sortable columns, a filter row, status tint on the row. Key-stat cards for a handful of named numbers. Cards with a 1px border for every other grouping.
Tabs: none. Pages and collapsible sections replace them. The brief's placement model attaches capabilities at suite / test / variable / criterion instead of a tab strip. A control that wants a tab needs a stated reason.
Criteria rows under each plot follow the old per-test page: a tinted
pill, metric · variable in 14px 600, the readout in mono 12px, then +,
wrap and − as flat icon buttons on the right. The readout is the CLI's own
criterion line, which already carries the score ("NRMSE 1.531e-01 vs
tolerance 1.000e-02 → FAIL"), so no number is printed twice. Clicking the row
opens its editor inline, indented 16px, with a 3px accent left rule, and the
plot above becomes its editing surface. One criterion open at a time.
The editor opens with one line saying how to use it — "Drag a ring on the plot, or edit the fields. Edits are staged as you make them; Save writes them." — and "Done" at the right of that line, the way out. Its fields sit in ONE wrapping row with the mode select first, so the plot and its editor fit a screen together; a multi-line field (a JSON list) takes a line of its own at the end of the row rather than where it was declared. A list table (control points, checkpoints, events, peaks) scrolls inside the editor after eight rows; only the rows scroll, so "Add row" below them is never scrolled out of reach.
The Variables drawer. The Test page's left rail holds two drawers, Tests
and Variables, one open at a time; open, a drawer is 320px at first (its
right edge resizes it) and the plots narrow, since they are fluid. The Variables drawer lists every variable in
the last result, grouped by dot prefix, tracked first, diagnostics last, with
a filter box: substring match with no wildcard, glob with * and ?, square
brackets literal because T[1] is a name; regex (.*) and match-case (Aa)
toggles at the right of the box, off by default. A checkbox per row means
"on the selected plot"; hovering a row offers "new plot"; small text names
the other plots a variable is on. The selected plot carries the 2px accent
inset ring. Indexed names fold into one row, pipe.T[1..20]. The indices
field in Modelica slice syntax (1:5:20) or a list, first drawn on this row,
is the add form's and the folded criteria row's (the owner's ruling of
2026-10-08, D152).
As built: the rail is 40px of labelled buttons, "Tests" and "Vars" (its
tooltip "Variables"); the open drawer's button sits on the selection tint.
The drawer pushes the plots narrower at every width, never overlays them, and
the page never scrolls sideways. Which drawer is open, and the Tests drawer's
filter, are remembered per browser tab, so a switch of test keeps the drawer
open. The Tests drawer is the Suite page's list without checkboxes: grouped
by package, short names, status badges, the open test's row on the selection
tint, and a control set of its own — a filter with .* / Aa, sort and
names menus, a group toggle and the count pills. A click opens that test in
the same tab; pending edits survive the switch.
The Variables drawer adds and takes off context series and makes watch plots
(D140: the plots themselves are derived from the criteria), acting on
the selected plot: the first, until a click on a card outside its plot area
selects another — its toolbar, its title, its margin, or a criteria row, whose
click also opens its editor. A criterion's swatch and its + / wrap / − do
not select, and the plot area keeps its zooms and handle drags. The target
line, "Checkboxes add context to plot 2 of 3", is a tag on the selection tint
with the selected card's ring
inside it; a click on it scrolls that card into view. With no plot it reads
"No plot yet: + new plot adds one" and the checks are grey. Groups: Tracked,
then each dot prefix (the component before any bracket: port[2, 1].C[1] is
in "port"), then "(top level)", then Diagnostics. A family is one row reading
pipe.T[1:3] with a swatch of four blocks, one per stop of the accent ramp,
light to dark and never a gradient — whatever hue a plot draws it in — and a
chevron that opens it to its members, each a row with its own check, at most
50, then "… N more". A row's check is a button with role=checkbox,
aria-checked true / mixed / false for all, some or none of its members
on the selected plot, and the row's text as its aria-label, with the muted
name after it in parentheses when the row shows one (a.Q (x)); it is not a
checkbox, whose repaint would stage an edit. A click adds whatever the plot
lacks as context (a mixed row adds), or takes the row off when the plot holds
all of it; the plot's own variable is checked and grey ("The plot's
subject"): a plot cannot lose it. "+ new plot" shows on the
row's hover and while focus is in the row; it makes a watch plot of that one
variable after the others — grey, with why, on a variable that already is a
plot's subject and on a family's row (open it and pick one).
Other plots holding the row read "plot 1, 3" at its right. The filter keeps
a variable whose name or label matches: a substring; with * or ?, a glob
matched against the whole text with brackets literal, as a spec's variables
list reads it, and matched without backtracking; with .* on, a Python
regular expression that may match anywhere, and a bad one is said under the
box. At most 200 rows are drawn, an opened family's members aside, then
"Showing 200 of 1,843 rows — narrow the filter". The drawer's row has no
indices field: it is in the add form and in the folded criteria row's editor
(D152).
Array families on a plot. Series from one array share a hue and ramp in
lightness by index, low to high (#90caf9 #42a5f5 #1976D2 #0d47a1 for
the accent hue); the legend folds to one entry; hover names the index. A
second array on the same plot takes another hue, and so does a series that
belongs to no array: green #a5d6a7 #66bb6a #388e3c #1b5e20, then
purple #ce93d8 #ab47bc #7b1fa2 #4a148c (Material 200 / 400 / 700 /
900, as the accent ramp is), then the list cycles. A lone series takes its
hue's third stop. A family is the names that differ in their last integer
index (pipe.T[3], pipe.medium[3].T), whatever comes before that index:
massPorts[4, 1].C[3] folds on its 3. A two-dimensional index after it
(a[1].b[2,3]), or as the last index (x[2,3]), makes no family. The card's
title, its muted names line, its plot-only line and the focus bar fold a
family as the report's plot titles do (pipe.T[1:3]). On a plot of several
series a reference is dashed in its series' colour and stays out of the
legend.
Plots are modular. A test page is a list of plots; a plot is a list of
series; a series is a variable name from the result plus an optional
expression (later: T - 273.15, p / 1e5, a unit label). The plots are
derived from the criteria (D140): one per scored variable or array family,
in tree order — one per tracked variable or array where nothing is scored.
Users add grey context series to a plot and watch plots of one variable. Only
the time plot exists now; profile, x-y, heat-map and 3-D kinds are not built.
A scored variable's criteria rows sit under its plot; a context series and a
watch plot have none. The legend sits above the plot area and holds the signals: actual,
reference and any overlays. Secondary axes appear only while a series on them
is visible; a second unit family on one plot gets one. A plot of several
series shows no overlays and no error traces — both belong to a plot of one
variable — and says so where it greys them.
As built: a plot's toolbar is the Error select, where the plot draws one
series, and Focus — Showing 1 of N plots: <name> — Show all N plots on
the accent tint above it, the focused plot's own button reading "Focused". The
error series is chosen by the select, one at a time, with its axis; a
criterion's drawing is shown or hidden by the swatch on its criteria row; the
overlays are in the figure as hidden traces, and the legend toggles them. The
wheel scrolls the page, never the plot: plots are stacked full-width, and a
plot that ate the wheel stopped the page dead (the report, one figure to a
page, keeps wheel zoom), and it shows no plotly.js tip: after a box zoom the
"Double-click to zoom back out" box lay over the sticky strip's buttons (the
double-click reset stays). After Focus comes Remove plot, on a watch
plot only — a derived plot has an icon there saying "This plot exists because
of its criteria." and goes when its last criterion goes; its click
does not select the card, and the selection stays on the same plot (the one
now in the removed plot's place, when the removed one was selected). A plot's
context series are listed in a row above it, each with a × that takes it
off; the Variables drawer does every
other layout edit. Reset layout ends the tree card's closed line while
the test's effective entry has a plots key, and stages removing it. Every
layout edit stages ONE op on the entry's plots key, which holds the extras
alone, and a run of
them reads as one pending edit; a
refused edit is a notification with the reason, and nothing moves. A
criterion's window is set from its fields, each with a ⌖ that takes the next
click on the plot (D149).
Icons. Material Icons in the tool (Quasar ships them). In mockups, inline stroke SVG on a 16px grid. Never emoji.
Header. Off-white, 1px border below, sticky. Left: "DSTF" wordmark,
library name, and the backend as plain text with its OS partition ("Python ·
linux"). Centre: the queue indicator. Right: pending-edit count with Save and
Discard, then a ⋮ menu holding Export (grey, with "no pending edits" under it,
while nothing is pending), Change backend… (a dialog holding the backend
select; grey, with the sentence that says why under it, while an edit is
pending or a run is in flight), Open in another backend…, dashboard (a link
into a new tab), the other theme (Dark theme / Default theme) and Stop server,
each with its full label — one layout at
every width. The backend was a select in the header's left side; the owner
moved it into the menu (Q13) when a rerun running, an edit pending and a
caption naming a new entry put Save on four lines at 900px and two at 1280px. Save's caption counts the entries it creates: a
pending edit to a test with no entry in the file as loaded writes one, "Save 2
spec edits (2 tests, 1 new entry) to test_spec.json". In every state,
including the busiest (a test running, an edit pending and a caption naming a
new entry), a header button takes at most two lines at 900px and one at
1280px, the count keeps one, and nothing is cut off or scrolls sideways. One
line at 900px is not the rule: it needs 960px. The header is never a solid
accent bar.
- Do keep the report and the tool byte-identical in colour and type; one stylesheet feeds both where they share markup.
- Do put numbers in mono and right-align them.
- Don't add a colour that is not in the tables above; derive tints from the status fill.
- Don't grey a control without its sentence.
- Don't use emoji, gradients, or drop shadows on cards.