Toolbench

Styling

Host stylesheets, and the class names and attributes they may rely on.

A tool's result is drawn inside a shadow root. Your page's CSS cannot reach in, so a host restyles it in one of two ways:

  1. Custom properties (--tb-accent, --tb-bg, --tb-font and the rest), set on tool-host from your page. Colours, fonts, radius, spacing. See Theming.
  2. Host stylesheets, adopted into every tool's shadow root by the runtime. Layout, size, borders, emphasis: anything CSS can do to the markup described below.

This document is about the second, and it is versioned API: the class names and attributes listed here are a contract, under the rule in What may change.

How host styles layer inside a toolinherited across the shadowboundaryadopted after the runtime'ssheetwin overcustom propertieson tool-host--tb-accent,--tb-font and therest@layer toolbenchthe runtime's ownrulesdefineToolHoststylesfor every tool, orper tool idyour sheetsoutside any layer

Custom properties reach the runtime's rules by inheritance, and your sheets sit in the same shadow root after them, outside its layer, so they win.

Passing a stylesheet

TypeScript
import { defineToolHost, setHostStyles } from "@toolbench/runtime"; defineToolHost({  source,  styles: [hostSheet, ".tb-out-table td { border: 1px solid var(--tb-border); }"],});

styles takes a CSSStyleSheet, a string of CSS, or a list of either. A string is parsed once and the resulting sheet shared by every tool on the page. To give one tool something extra, pass a map, where "*" applies to every tool and comes first:

TypeScript
defineToolHost({ source, styles: { "*": [base], "my-tool-id": [extra] } });

setHostStyles(styles) replaces the host's sheets on every tool already on the page and on every one created later, without reloading, which is what a theme switch needs. setHostStyles(undefined) removes them.

A sheet your page adopted into a shadow root by hand is left where it is, after the runtime's own.

The guarantee: your rules win

Every rule the runtime ships is inside @layer toolbench. A rule in your sheet that is not inside a layer beats every runtime rule, whatever the specificity of either. You do not need to match the runtime's selectors, or read them, to override them.

So .tb-out-table td { text-align: start } wins over the runtime's more specific .tb-out-table td[data-align="end"] { text-align: end }. Without the layer it lost, and stacked table rows kept half their values pinned to the right edge.

If you put your own rules in a layer, they are compared with toolbench by layer order instead, which is standard CSS: a layer declared later wins.

The runtime's sheet is always first in the root, and yours after it, whichever was adopted first.

Where adoptedStyleSheets is unavailable, the runtime's sheet and yours become <style> elements in the same order, so the cascade is the same.

What you can select

Structure

ClassElement
.tbThe frame around everything a tool draws. A size container, so @container queries measure the tool, not the viewport
.tb-head, .tb-title, .tb-name, .tb-blurb, .tb-markThe heading, the tool's name, its one-line blurb, and a deprecated or retired mark
.tb-bodyForm, actions, status and output
.tb-facade, .tb-facade-hintA closed card, and its "Try it" control
.tb-form, .tb-field-row, .tb-label, .tb-desc, .tb-input, .tb-textarea, .tb-select, .tb-checkbox, .tb-sliderThe generated form
.tb-seg, .tb-seg-optA segmented select (#203): the radio group, and each option's <label> with its radio inside. .tb-seg carries data-stack while the options are stacked radios, and aria-invalid when a result names the input. Its row is a <fieldset> and its .tb-label a <legend>
.tb-nowrapA date inside an error message (#203), kept on one line
.tb-color-row, .tb-swatchA colour input (#203): the row holding the native swatch, input[type="color"], and the hex box, which is the .tb-input
.tb-secret-row, .tb-eyeA secret input (#203): the row holding the password field and the eye button, which carries aria-pressed="true" while the value shows
.tb-checks, .tb-check, .tb-chips, .tb-chip, .tb-countA multiselect (#204): the group of checkboxes, each option's <label> with its checkbox inside, or the group of chips, each a <button> with aria-pressed; and the count line under it. An option that cannot be chosen at max carries aria-disabled="true", on the checkbox or the chip. The group carries aria-invalid when an error names the input. Its row is a <fieldset> and its .tb-label a <legend>
.tb-rows-box, .tb-rows-head, .tb-rows, .tb-row, .tb-row-cells, .tb-cell, .tb-handle, .tb-remove, .tb-rows-empty, .tb-rows-foot, .tb-addA rows input (#204): the group, the column headings over two or more columns, the <ol> and each <li>, its cells (each .tb-cell carries data-label, its column's label, drawn before the cell when the tool is narrow), the move and remove buttons, the empty state, and the row of Add and the count. .tb-rows carries data-columns; a row being moved carries data-moving; Add carries aria-disabled="true" at maxRows. An error naming a row is a .tb-field-msg.tb-row-msg inside its <li>
.tb-field-msgThe message under an input an error names (#204), the field's name first. While no error names the input the same element is the unseen .tb-sr slot
.tb-details, its summary, .tb-details-bodyAn input's details (contract version 8): a <details> after the description, closed until the reader opens it. The body takes the same markup classes as .tb-desc
.tb-desc p, .tb-desc ul, .tb-desc li, .tb-desc code, .tb-desc strong, .tb-desc [data-tone]A description's markup. .tb-desc is a <p>, or a <div> when the description has more than one block. data-tone is good, warn, bad, muted or accent
.tb-samples, .tb-sampleThe row of sample inputs, and each button
.tb-share-bar, .tb-share, .tb-share-note, .tb-share-topThe share button (#227), the note beside it, and the row that puts it at the end of the title. The button carries data-copied while it shows the check mark, and is also ::part(share)
.tb-actions, .tb-run, .tb-reset, .tb-progressThe Run button, the Reset button beside it (carries hidden while the inputs are at their starting values), and the progress bar
.tb-statusThe visible status line
.tb-outputWhere the result is drawn
.tb-more, .tb-disclose, .tb-restA truncated result's notice, its expand button, and the parts it reveals
.tb-copy-bar, .tb-copy, .tb-copy-noteUnder a code or table result: the row, its Copy button, and the note beside it saying whether the copy worked. Not on a closed card
.tb-footLinks under the tool. Absent with links="none", and holds only "Open the full tool" with links="page"

Result parts

Every part of a result has data-kind on its root element, naming its kind: fields, text, code, table, series, heatmap, pie, radar, treemap, venn, graph, bytes, group or error. A part the tool gave a role also has data-role, with the tool's string unchanged.

KindRootInside it
group.tb-groupOne root per part
fields.tb-out-fields.tb-fields (a <dl>, for grouped and ungrouped fields alike), .tb-field-group with an <h4> for a group, .tb-field (with <dt> and <dd>), .tb-value, .tb-note. The <dd> carries .tb-mono unless the field says mono: false
table.tb-out-tableA <table>, with <caption>, <thead>, <tbody>, then .tb-copy-bar. A cell carries .tb-mono when it or its column says mono, and a header when its column does. A rowHeader column's cells are <th scope="row"> with a cell's attributes, and its heading carries data-row-header
text.tb-out-textA <p>, or a <pre class="tb-mono">
code.tb-out-code<pre><code class="language-…">, then .tb-copy-bar
bytes.tb-out-bytes.tb-bytes-grid, .tb-bytes-row, .tb-bytes-offset, .tb-bytes-hex, .tb-bytes-ascii, .tb-byte, .tb-bytes-legend, .tb-bytes-caption
every chart kind.tb-out-chart, plus .tb-out-chart-card on a cardThe chart's own markup. Not covered: see below
graph.tb-out-graph.tb-graph-plot (the picture, one tab stop), .tb-graph-readout, .tb-graph-data (the edge table, a <details>), .tb-graph-message (why a graph is listed instead of drawn, or that it is being laid out). Inside the picture, the parts below
error.tb-out-error.tb-error-message

A chart's internal markup, the SVG and its classes, is not part of this contract. Style a chart through its root and the series colour properties. Its insides are drawn geometry and will change.

Chart text and width

A chart is laid out for the width it is given, one SVG unit to one CSS pixel, and laid out again when that width changes. Its labels are therefore the size you set at any width, and nothing in it is stretched. Two properties size it, set on tool-host or anything around it:

PropertyDefaultWhat it does
--tb-chart-font0.75remThe size of every label inside a chart. The margins are measured from the labels, so a larger size makes room for itself rather than overlapping
--tb-chart-max-width100%The widest a chart is drawn. Wider than this, the chart keeps this width at the start of its row
CSS
/* Larger chart labels, and no chart wider than a reading column. */tool-host { --tb-chart-font: 0.875rem; --tb-chart-max-width: 48rem; }

Below 480px a chart takes a narrow layout: fewer gridlines, each horizontal bar's name on its own line above its bars, a pie's key under the pie, and a Venn diagram's set names in a key under the picture.

⚠️ A chart's first drawing, made before it is on the page, is at a guessed width of 640 pixels. It is replaced by one at the measured width, usually before anything is painted, so a script that keeps a reference to the figure returned by render holds the replaced one. Find it again by data-kind or data-part instead. A script that points at a chart or measures one should wait for data-fitted on the figure: it marks the drawing at the measured width, the one that stays. Counting frames is not enough on a busy machine.

Inside a graph

A graph's picture is different: a tool names its nodes, edges, groups and lanes, so a host can style them by name. Every one carries data-kind, and data-role when the tool gave it one. Where they are drawn is geometry and not covered; what they are, below, is.

data-kindElementAlso carries
node.tb-graph-node, a <g> with .tb-graph-shape (the outline), .tb-graph-name and .tb-graph-notedata-id, data-shape, data-color
collapsed-group.tb-graph-node standing for a closed group's membersdata-id (the group's), data-color
edge.tb-graph-edge, a <g> with .tb-graph-line, .tb-graph-mark for each end and .tb-graph-labeldata-line (dashed, dotted), data-width (thin, thick), data-color, data-routed
group.tb-graph-group, the rectangledata-id, data-depth, data-color
group-titleThe group's title, which closes itdata-id
lane.tb-graph-lane, the banddata-id
badge.tb-graph-badge

data-color is the tool's tone or color, bad or series-3 say, and every coloured rule reads one custom property, --c, so a host recolours a category by its data-color alone. A mark carries data-end (head, tail) and data-fill (solid, hollow, none). A node or edge on the highlighted path carries the class tb-path, and while a reader points at or focuses a node, it and its edges carry tb-hot and its neighbours tb-near. A row of the edge table for a node with no edges carries data-orphan.

A graph scaled to fit carries a button above its drawing, .tb-graph-wide in a .tb-graph-bar, with aria-pressed="true" while the graph is at full size (#198). It also carries part="wide-toggle", so the page's own stylesheet can reach it from outside the shadow root, where a styles sheet would use the class:

CSS
/* In the page's stylesheet, not in `styles`: ::part is how a page reaches into the shadow root. */tool-host::part(wide-toggle) { border-radius: 999px; color: var(--tb-accent-text); }tool-host::part(wide-toggle):hover { border-color: var(--tb-accent); }

The share button (#227) is part="share" the same way, so a page can restyle it without a styles sheet:

CSS
tool-host::part(share) { border-radius: 999px; }tool-host::part(share):hover { border-color: var(--tb-accent); }
CSS
/* Decisions in a flowchart, drawn heavier: the tool set role: "decision" on them. */.tb-graph-node[data-role="decision"] > .tb-graph-shape { stroke-width: 2.5; }/* Development-only dependencies, faded. */.tb-graph-edge[data-role="dev"] { opacity: 0.5; }

Attributes

AttributeOnValues
data-kindEvery part's rootThe part's kind
data-roleA part's root, a .tb-field, or a .tb-field-rowWhatever role the tool set on the part, field or input. Absent when it set none
data-input.tb-field-rowThe input's id
data-type.tb-field-rowThe input's type: text, textarea, number, select, toggle, date, color, secret, multiselect, rows
data-partA part's rootWhatever id the tool set, unique in the result. Absent when it set none. host.reveal(id) goes to it
data-labelEvery table cellIts column's label. Absent for a column with an empty label
data-tone.tb-field, table cellsnormal, good, warn, bad, muted
data-alignTable cells and headersstart, end
data-row-headerThe heading of a rowHeader columnPresent or absent (contract version 8)
data-wrapTable cells and headersprose, none: the column's or the cell's wrap (contract version 8). Absent when neither says
data-series.tb-byte, .tb-bytes-legend li1 to 6: the series colour of a range with no tone, in order, round again after six. Absent on a toned range
data-lang.tb-out-codeThe code's language
data-tokA token a tool's grammar painted in a textarea, inside .tb-paintkeyword, string, comment, operator. Colour only: a rule that changes a token's width moves the caret off the glyph
data-state.tb-outputrunning, idle
data-stale.tb-outputPresent when the inputs have changed since the result was drawn
data-statustool-host itselfdeprecated, retired. Absent for a live tool
data-fitted.tb-out-chartPresent once the chart is laid out at its measured width and nothing is left to change. Absent on the first drawing, and on a new drawing after a resize until it is measured. See Chart text and width

role is the tool's to set: { kind: "fields", role: "stats", fields } names a part, and { label, value, role: "verdict" } names one field. The runtime attaches no meaning to it, so a host that styles nothing sees no difference.

The form is named the same way (contract version 8 for role): each .tb-field-row carries data-input and data-type, and data-role when the input has a role. Its controls read --tb-input-bg, --tb-input-border and --tb-input-fg, and its description --tb-desc-fg, so a host can recolour one input by setting a property on its row rather than restating the runtime's rules:

CSS
.tb-field-row[data-role="threshold"] { --tb-input-border: var(--tb-accent); }.tb-field-row[data-input="values"] { --tb-desc-fg: var(--tb-muted); }

.tb-mono is the one class for "this value is a token", on a field's <dd> and on a table cell alike, so one rule reaches both: .tb-mono { font-family: var(--tb-mono); } is the runtime's, and a host that wants its tokens in another face changes --tb-mono or writes its own .tb-mono rule. A field is mono unless it says mono: false (contract version 8); a table cell is mono only when it or its column says so.

Examples

All four are plain CSS in a host sheet, and the styling page applies them to real results in the browser tests.

A prominent, colour-coded verdict. One field, named by the tool with role: "verdict":

CSS
.tb-field[data-role="verdict"] { display: block; padding: 0.75rem 1rem; border-radius: var(--tb-radius); border: 1px solid var(--tb-border); }.tb-field[data-role="verdict"][data-tone="bad"]  { background: color-mix(in oklab, var(--tb-bad) 12%, transparent);  border-color: var(--tb-bad); }.tb-field[data-role="verdict"][data-tone="good"] { background: color-mix(in oklab, var(--tb-good) 12%, transparent); border-color: var(--tb-good); }.tb-field[data-role="verdict"] dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; }.tb-field[data-role="verdict"] .tb-value { font-family: var(--tb-font); font-size: 1.35rem; font-weight: 650; }

Stat tiles. A part named role: "stats". grid-template-columns: 1fr on the tile is needed: the runtime lays a field out as label and value side by side, and a tile that keeps that puts both in a 9rem box and wraps the note a letter at a time.

CSS
[data-role="stats"] .tb-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.5rem; }[data-role="stats"] .tb-field { grid-template-columns: 1fr; gap: 0.25rem; padding: 0.75rem; border: 1px solid var(--tb-border); border-radius: 8px; background: var(--tb-surface); }[data-role="stats"] .tb-value { font-size: 1.5rem; font-family: var(--tb-font); font-weight: 650; }[data-role="stats"] .tb-note { display: block; margin: 0; }

Bordered tables. The good and muted tones are coloured by the runtime's own sheet.

CSS
.tb-out-table table { border: 1px solid var(--tb-border); }.tb-out-table th, .tb-out-table td { border: 1px solid var(--tb-border); }

Stacked rows on a narrow tool. .tb is the query container, so this follows the tool's width, not the screen's:

CSS
@container (max-width: 30rem) {  .tb-out-table thead { display: none; }  .tb-out-table tr { display: grid; padding: 0.5rem 0; border-bottom: 1px solid var(--tb-border); }  .tb-out-table td { border: 0; text-align: start; }  .tb-out-table td[data-label]::before { content: attr(data-label); display: block; font-size: 0.72rem; color: var(--tb-muted); font-family: var(--tb-font); }}

A table with a rowHeader column has th cells in its body, so write :is(td, th[scope="row"]) where this says td.

The built-in narrow layout. A table whose columns say wrap (contract version 8) already has one, in the runtime's layer, under @container (max-width: 34rem): the table keeps its own width and scrolls inside its part, headings and none cells stay on one line, a prose cell is 26ch wide and at least 20ch, and a rowHeader column, with its heading, stays in view while the rest scrolls, as does the caption, wrapping to the part's width. A table that says nothing about wrapping keeps the layout it had. Wherever a table scrolls, a shadow at an edge says there is more that way; it is painted over --tb-scroll-bg, set on .tb-out-table to the frame's colour, which a host that recolours the frame of an embedded tool sets to match. Any of it can be overridden like any other runtime rule.

What may change

Under semver, against @toolbench/runtime: