Skip to content

Themeable burned captions

The halo, numbered badge, callout and the connector that joins the callout to its target, as docsxai burn and docsxai pack draw them into a screenshot, come from one theme. The interactive viewer (docsxai render) reads the same values through CSS variables, so a burned image and the live page show the same caption.

A workspace without a theme draws the look docsxai always had, byte for byte. Everything below is opt-in.

The burn.theme block of the workspace’s .docsxai.json is the source of truth:

{
"schema": "docsxai/workspace@1",
"burn": {
"theme": {
"preset": "soft",
"accent": "#0b6e4f",
"font": "fonts/brand-regular.ttf"
}
}
}

burn, pack, pack --check and render read it. docsxai-viewer site does not: the Starlight emitter takes its accent from --accent or the style artifact.

An optional tokens file fills the block (see Tokens file). For the whole path from raw captures to a hash-named pack, see Themed caption packs.

Four layers apply in this order, each one overriding the one before it:

  1. The default look.
  2. preset, when set.
  3. The tokens file named by from, when set.
  4. The keys written in the block.

The callout outline colour follows ink until a preset, the tokens file or the block sets callout_border_color.

KeyTypeRange or valuesDefaultWhat it sets
presetstringclean, soft, high-contrast, readablenoneA built-in look, see Presets.
fromstringpath to a .json file inside the workspacenoneThe tokens file.
tokensobjecttheme key to token pathnoneReads a key from another path, and requires it to exist.
accentcolour#rgb or #rrggbb#e8590cHalo outline. Its glow is the same colour at 35% opacity.
inkcolour#rgb or #rrggbb#1c1c1cCallout text.
callout_backgroundcolour#rgb or #rrggbb#fffCallout fill.
callout_border_colorcolour#rgb or #rrggbbthe ink colourCallout outline, and the connector that joins it to its target.
callout_border_widthnumber0 to 6 px1Callout outline width. 0 draws no outline and the connector uses ink.
radiusnumber0 to 24 px7Callout corner radius. The halo’s radius is 4/7 of it.
shadowstringCSS box-shadow, up to 3 layers, or nonenoneCallout shadow. Layers are <x> <y> [<blur> [<spread>]] <colour>.
badge_shapestringcircle, rounded, squarecircleBadge corners: a pill, 6 px, or square.
badge_fillcolour#rgb or #rrggbb#c2410cBadge fill. Its digits and outline are white.
font_sizenumber8 to 32 px14Callout text size.
line_heightnumber8 to 64 px, at least font_size19Callout line pitch.
padding_xnumber0 to 40 px11Callout horizontal padding.
padding_ynumber0 to 40 px8Callout vertical padding.
fontstringpath to a .ttf or .otf inside the workspacethe bundled InterCallout and badge font, see Font.
font_weightnumber400 or 600400Callout text weight. The font file must have it, see Font.
connector_stylestringauto, line, chevron, arrowautoHow a callout joins its target, see Connectors.
caption_stylestringfull, badge, autofullCaption, or badge only, see Caption style.
arrow_sizenumber4 to 24 px8Triangle length for connector_style: "arrow". Its base is 7/4 of that wide.
max_width_rationumber0.2 to 10.62Widest callout as a share of the screenshot, see Scale.
scalenumber or "auto"0.5 to 4, or auto1Multiplies every caption dimension, see Scale.
display_widthnumber or object240 to 2400 px, or an object720CSS px shown at, or a wide and narrow pair, see Scale.

Numbers are plain values without a unit: px for sizes, a share for max_width_ratio, a factor for scale. Colours are stored as lowercase #rrggbb.

A screenshot wider than the column it is shown in gets scaled down by the page, and the caption drawn into it shrinks with it. A 1440 px screenshot in a 720 px column shows 14 px text at 7 px.

scale multiplies every caption dimension: text size, line height, padding, outline, radius, shadow, connector, badge, halo and the callout’s width. The layout (which side the callout takes, where it slides) is decided at base size and then multiplied, so a scaled caption sits where the unscaled one sits.

  • A number such as 1.5 or 2 is used as written.
  • "auto" uses max(1, screenshot width / display_width), at most 4, so a screenshot wider than its column gets a larger caption and a narrower one keeps base size. A 1440 px screenshot with the default display_width of 720 gives 2. A 390 px screenshot gives 1.

display_width is the width in CSS px the page shows the screenshot at. It only matters for "auto". Wide and narrow screenshots shown at different widths take an object, see Wide and narrow images.

When the page shows the screenshot at displayWidth CSS px, a caption length ends up on screen as:

onscreen = fontSize * auto * (displayWidth / imageWidth)

With auto = imageWidth / displayWidth that is fontSize itself, so readable text is 15 px, badge digits 12 px and the halo outline 2 px. A 1440 px screenshot shown at 720 px gives auto 2 and a display factor of 0.5. A 780 px phone screenshot shown at 358 px needs display_width: 358: auto is 2.18 (rounded up to 2 decimals) and the display factor 0.46. With the default 720 that screenshot gets auto 1.09 and 15 px text shows at 7.5 px. The cap of 4 covers a 1440 px screenshot shown at 360 px; a screenshot more than 4 times its display width shows captions under base size.

max_width_ratio limits the callout’s outer width to that share of the screenshot. At the default 0.62 the limit applies to callouts planned around obstacles (242 px on a 390 px screenshot), and other callouts stop at 280 px at base size. A ratio under 0.62 applies to every callout.

scale, display_width, arrow_size, max_width_ratio, connector_style and caption_style apply to burned images only. The interactive viewer shows the screenshot at its own size and keeps its own arrow, so it reads the colour, shape, type and font_weight keys and ignores these six.

Docs sites show a wide desktop capture and a phone capture at different widths: 1440 px shown at 720, 780 px shown at 358. One number cannot fit both, and display_width: 720 gives the phone capture an auto factor of 1.09 and 7.5 px text. display_width also takes an object:

{
"burn": {
"theme": {
"preset": "readable",
"display_width": { "default": 720, "narrow": 358, "narrow_below": 900 }
}
}
}

A screenshot narrower than narrow_below image pixels (default 900) uses narrow, any other uses default (720 when absent). With that block a 780 px capture gets auto 2.18 and a 1440 px capture gets 2, so both read at 15 px on the page.

  • narrow is required. default and narrow_below must be positive numbers when written.
  • Unknown keys, a non-number and a non-positive value stop the command and name the key path, such as display_width.narrow.
  • The number form has no narrow width. A number written in the block replaces an object from a lower layer.
  • A tokens file can supply the object through tokens, as the value of the mapped token.
  • It only changes scale: "auto". An explicit scale ignores it, and the viewer’s CSS variables do not read display_width.
  • In code the resolved theme carries displayWidth, displayWidthNarrow and narrowBelow (the last two are null for the number form), and captionScale(theme, imageWidth) picks the width.
  1. Find the width the site shows screenshots at: the content column, in CSS px. 700 to 760 is typical.
  2. Set display_width to it and scale to "auto", or take the readable preset, which does.
  3. Set font_size to the page’s body size (15 or 16) so the caption reads like the text beside it.
  4. Vendor a font file next to the pack with font, so the caption matches the site’s typeface and the output stays byte-stable.
{
"burn": {
"theme": {
"preset": "readable",
"display_width": 720,
"accent": "#0b6e4f",
"font": "fonts/brand-regular.ttf"
}
}
}

Screenshots narrower than display_width keep base size, so one block serves mobile captures and wide desktop ones. When the phone captures are shown narrower than their pixel width, use the object form from Wide and narrow images.

When an annotation carries obstacles, the burner scores every callout position and keeps the cheapest. A themed burn with the line connector also adds text it finds in the screenshot’s pixels, see Placement with the line. From most to least important:

  1. The callout covers no page content, halo, badge or earlier callout.
  2. The connector crosses none of them. Each px of stem inside content costs more than a px of callout covering it, and a brush of under 2 px at a box edge does not count.
  3. The callout sits on the side named by placement.side or arrow_style. That outweighs a sliver of overlap, about 2 px of content along the callout’s width, and loses to anything deeper or to a side with no room in the image.
  4. The stem is short.

When no spot is clear at full size, the burner retries with the callout’s side padding cut by up to 6 px (never below 8 px) and keeps that box if it then fits clear. With obstacles, a numbered badge is kept fully inside the screenshot, 2 px from the edge. Without obstacles, an arrow burn places its badge and callout as before, so a badge on a target at the image edge can still be cut off there.

connector_style sets what joins a callout to its target. It is new in the next release. Until then a themed burn draws the arrow.

  • line: a 2 px line from the middle of the caption edge that faces the target to the outside of the halo ring, ending in a dot of 2 px radius. It starts on the caption’s outer edge, so there is no gap and it does not run under the caption. The start keeps radius + 2 px away from the caption’s corners. When the target is off the caption’s span, the line takes one right-angle elbow halfway.
  • chevron: the same line ending in an open chevron with 5 px arms.
  • arrow: the filled triangle and stem docsxai always drew, with the halo on the target’s edge and the badge placed as before.
  • auto, the default: line for a burn with a theme, arrow for a burn without one.

Line width, dot radius and chevron arms are all multiplied by scale.

The line comes with a clean halo and badge. The halo ring is 2 px wide and sits 3 px outside the target, with a 3 px glow, so it never covers text inside the target. Its corner radius is 4/7 of radius plus 5 px. The numbered badge sits 1 px off a corner of the target, clear of the element. The burner tries the top-left, top-right, bottom-left and bottom-right corners, then beside the middle of the left and right edges, then inside the target’s top-left corner. It takes the first spot inside the image that covers no text or other annotation, else the one that covers least.

  • A caption rests 20 px from its target, so 15 px of line show past the 5 px ring.
  • The burner reads text from the screenshot’s pixels. It cuts the image into 8 px cells, marks a cell busy when it holds at least 12 luminance steps of 48 or more (at least 4 across and 4 down), and merges busy cells into boxes that join the annotation’s obstacles. A listed box with nothing drawn in it is dropped. A capture sidecar lists at most 40 boxes, and a burn without obstacles gets the same protection.
  • A caption that covers text at its resting spot moves out up to 176 px to empty space. A clear spot 150 px away beats a covered one beside the target.
  • When nothing within 176 px is clear, a caption above or below its target docks in a band 12 px from the top or bottom edge of the image when the band is within 45% of the image height of the target. A connector that crosses text costs more than one through a gap, so docking takes the gap when there is one.
  • When the usual box covers something, the burner tries a box up to 560 px wide (fewer lines), then both again with the text one step smaller: 16 px burned on a screenshot 900 px wide or more, 14 px below that. It keeps the first clear layout, else the one that covers least.
  • When the caption is clear but its line still crosses a listed obstacle, another halo, badge or caption, the burner looks for a spot with a straight line that crosses nothing, then for a line with one right-angle elbow, as Routing the line describes. The burn report records each annotation’s route (straight, elbow or none) and connector_crossings (the boxes the drawn line still crosses, 0 once routed). This is on main.
  • A pinned arrow (placement.pin_arrow) keeps the distance steps of an arrow burn, up to 320 px past the gap, and does not dock. Its nudge sets the distance. Side hints work as before.
  • docsxai burn --no-connector-outline skips reading the screenshot’s pixels. The line keeps the colour of callout_border_color, and the burner finds no text, so it places captions from the listed obstacles alone.

The line takes callout_border_color, or ink when callout_border_width is 0. The burner averages the screenshot under the line and checks that colour against it. Under 3:1 it takes whichever of ink and callout_background has more contrast there, when either beats it. The line has no outline.

A burn without a theme keeps the triangle and its bytes. To keep the triangle in a themed burn, set connector_style: "arrow".

To compare the looks, run node scripts/burn-trackxai-sample.mjs after pnpm --filter @docsxai/viewer build. It burns three desktop screens in light and dark and a 390 px phone screen in dark with the examples/burn-theme/trackxai-light.tokens.json and trackxai-dark.tokens.json pair, once with arrow at 14 px and once with the theme as written. It prints the 14 PNG paths under /tmp/burn-before-after.

node scripts/burn-trackxai.mjs --raw <dir> --out <dir> burns a directory of trackxai raw captures into a screens pack (its header lists every flag). Add --webp to write a lossless <name>.<hash>.webp beside each new PNG, made with cwebp -lossless -z 9. A twin is kept only when it is under 100 kB and smaller than the PNG; otherwise the script removes it and prints a warning with both sizes. cwebp comes from the webp package (brew install webp, apt install webp); without it on PATH the run stops before it burns anything. --badge-only board.390,ticket.en.dark.390 burns those frames as badges and rings only, with no caption or connector. An entry is <screen>.<width> for every locale and theme, or <screen>.<locale>.<theme>.<width>. The same list can sit under the badge_only key of scripts/burn-trackxai.overrides.json, or in the file named by --overrides. This is on main.

remotxai-light.tokens.json and remotxai-dark.tokens.json carry the remotxai docs look: its teal accent and neutral surfaces, 17 px text, and a display_width of 800 (390 under 900 px), so a 1280 capture shows at the content width and a 390 one at full width. The docs font is Instrument Sans, which a tokens file cannot name: set font to a copy of it in the workspace.

caption_style is new in the next release. It applies with the line connector (connector_style line or chevron, or auto with a theme). An arrow burn always draws the caption.

  • full, the default: every annotation gets its caption.
  • badge: no caption box and no connector. Each target gets its ring and a numbered badge, numbered by its place in the list when it has no index. Use it where the page prints the callout text as a numbered list under the image.
  • auto: the caption when a spot clear of text exists and its connector, straight or with one elbow, crosses no text, else the badge.

The burn report marks each badge-only annotation with "caption_style": "badge", whether badge or the auto fallback chose it.

node scripts/burn-probe-batch.mjs --raw <dir> --out <dir> burns every frame of a raw capture folder with the trackxai tokens and writes a report of where each caption, connector and badge landed. It lives in scripts/ of a docsxai checkout, so run it from one after pnpm --filter @docsxai/viewer build.

Terminal window
node scripts/burn-probe-batch.mjs --raw raw --out probe --theme dark --locale en --widths 1280,390

--raw is read in either layout, detected from the file names. Other files (manifests, READMEs, step metadata) are ignored:

  • Flat: <screen>.<locale>.<theme>.<width>.png with a .json sidecar beside it (trackxai/raw-capture-sidecar@1, annotations with bounding_box).
  • Rawcap: <flow>/<step>/<locale>.<theme>.<viewport>.png with a .json sidecar beside it (width, height, annotations with index, copy, bbox and optional obstacles). A viewport is a width (390) or a name that ends in one (mobile-390).

--theme (default dark), --locale (default en) and --widths (default 1280,390) pick the frames. The tokens file is --tokens-dark or --tokens-light, else examples/burn-theme/<project>-<theme>.tokens.json, where --project trackxai|remotxai (default trackxai) picks the project. Captures under 900 px burn with caption_style: "auto", the others with full.

<out>/previews/ holds the burned PNG of each frame, and <out>/report.md the summary (frames, annotations, OK, CHECK, skipped for no bounding_box), the CHECK rows, then one row per frame and annotation: mode (full, badge or skipped), side, connector length in px, px² of the caption over listed text and over the phone header and bottom nav, whether the connector crosses listed text, whether the badge sits next to its target, and the verdict.

A row is CHECK when the caption covers listed text or the phone header or nav, the burn marked it unplaceable, it covers another annotation’s target, caption or badge, its badge is on its target, on listed text or the phone chrome, or more than 6 px from the target, or (phone captures only) its connector crosses listed text. Desktop rows show the crossing without holding it against the verdict.

A portrait capture under 900 px wide is treated as a phone screen. Its top 56 px (the header), bottom 56 px (the nav bar), the floating action button above the nav bar’s right end (76 by 80 px) and a 16 px strip on each side are content. Captions stay off them, and badges stay off the header, nav bar and button. A caption docks 64 px from the top or bottom edge, under the header or above the nav bar. On a screenshot under 900 px wide the smaller text step is compact: 16 px lines for the 14 px text, 4 px of vertical padding and 8 px of horizontal padding.

Those sizes are CSS px. A 2x capture (780 by 1688 px for a 390 by 844 viewport) is burned with the pixelRatio option set to 2 (scripts/burn-trackxai.mjs --dpr 2): the width classes and display_width read the CSS width (780 / 2), the bands, margins, text floors and the pixel text search double, and scale: "auto" still gives 780 / display_width. Sidecar boxes are in the PNG’s own pixels. docsxai burn sets pixelRatio from the device_scale_factor that docsxai run --device-scale-factor writes into annotations.json, so a workspace captured at 2x needs no flag.

A burn with a theme balances the callout’s lines. It keeps the line count that filling each line gives, then narrows the box to the smallest width in whole px that holds that count, so the lines come out about even. A one-letter word such as Spanish a, y or o, and the list number in front of the copy, stay on the line of the word after them. A word wider than the line, such as a path or a URL, breaks after /, -, _, . or : before it breaks between letters.

A burn without a theme fills each line, as it always did.

Every preset sets every key except font. clean, soft and high-contrast keep scale at 1, font_weight at 400, arrow_size at 8, max_width_ratio at 0.62, display_width at 720, connector_style at auto and caption_style at full.

Keycleansofthigh-contrast
accent#2563eb#7c3aed#d10000
ink#111827#2e1065#000000
callout_background#ffffff#faf5ff#ffffff
callout_border_color#1f2937#6d28d9#000000
callout_border_width112
radius6124
shadow0px 2px 8px 0px rgba(17,24,39,0.18)0px 6px 18px 0px rgba(76,29,149,0.22)none
badge_shaperoundedcirclesquare
badge_fill#1d4ed8#6d28d9#000000
font_size141415
line_height202021
padding_x121412
padding_y8109

high-contrast is black text on a white callout (21:1) and white digits on a black badge (21:1).

readable is for docs sites. It sets scale to "auto" and max_width_ratio to 0.55, which limits every callout to 55% of the screenshot’s width. It differs from the others in these keys:

Keyreadable
accent#c2410c
ink#111827
callout_background#fafafa
callout_border_color#111827
callout_border_width1
radius8
shadow0px 4px 12px 0px rgba(17,24,39,0.28)
badge_shapecircle
badge_fill#9a3412
font_size15
line_height21
padding_x14
padding_y10
max_width_ratio0.55
scaleauto

Ink on the callout is 17:1 and the badge digits are 7.3:1. All four presets keep both text pairs at WCAG AA (4.5:1) or better, and a test checks that.

Any key in the block overrides the preset:

{ "burn": { "theme": { "preset": "clean", "accent": "#0b6e4f", "radius": 2 } } }

from names a JSON file inside the workspace. Two shapes are read:

  • W3C design tokens: groups of { "$value": ..., "$type": ... } leaves, with {group.token} aliases.
  • A plain map, nested ({ "color": { "accent": "#e8590c" } }) or flat ({ "color.accent": "#e8590c" }).

Both flatten to dotted paths. Each theme key reads one default path:

Theme keyDefault token pathValue read
accentcolor.accentcolour: hex string, or an object with a hex member
inkcolor.inkcolour
callout_backgroundcolor.callout.backgroundcolour
callout_border_colorcolor.callout.bordercolour
callout_border_widthborder.callout.widthdimension: 2, "2px", "0.125rem" or { "value": 2, "unit": "px" }
radiusradius.calloutdimension
shadowshadow.calloutCSS shadow string, or a W3C shadow object (color, offsetX, offsetY, blur, spread), or a list of them
badge_shapeshape.badgecircle, rounded or square
badge_fillcolor.badge.backgroundcolour
font_sizefont.size.captiondimension
line_heightfont.line-height.captiondimension in px (a unitless multiplier such as 1.4 is refused)
padding_xspace.callout.xdimension
padding_yspace.callout.ydimension
fontfont.filepath to a .ttf or .otf, relative to the workspace root
font_weightfont.weight.caption400 or 600
connector_styleshape.connectorauto, line, chevron or arrow
arrow_sizesize.callout.arrowdimension

A rem is 16 px. A key whose default path the file does not define is left at the preset or default.

caption_style, max_width_ratio, scale and display_width are render settings with no design-token equivalent, so they have no default path. Set them in the burn.theme block, or point one at a token with tokens.

To read a key from a different path, map it in tokens:

{
"burn": {
"theme": {
"from": "design/tokens.json",
"tokens": { "accent": "brand.primary", "ink": "brand.text" }
}
}
}

A key named in tokens must exist in the file. A missing one stops the command with:

.docsxai.json burn.theme: ink: burn.theme.tokens maps it to "brand.text", but design/tokens.json has no such token

A tokens file that defines none of the default paths and maps none is an error too, since it is most likely the wrong file.

font points at a TTF or OTF file inside the workspace. Vendor the file in the workspace, next to the rest of the doc pack, so the output stays byte-stable: the burner reads that file and no system font. Without font the bundled Inter Regular is used.

The file is refused when it does not exist, is a symlink, is over 16 MiB, is a collection (.ttc), is a WOFF or WOFF2 file, or lacks the head, hhea, hmtx or cmap tables the burner measures with. One weight is used: the file’s own. font_weight selects 400 or 600 and the file must be that weight, for example a semibold TTF with font_weight: 600. The bundled Inter is regular only, so 600 without a font prints a warning and the text stays 400.

In the interactive viewer the font is embedded in each flow page as a data: URI, and the page’s content security policy gains font-src data: and nothing else. A page built without a font file keeps the policy it always had.

The burner and the viewer use the theme as written. burn, pack and render print a warning when a text pair falls under WCAG AA (4.5:1):

  • ink on callout_background.
  • White digits on badge_fill.
burn: warning: burn.theme: ink on callout_background is 2.32:1, WCAG AA needs 4.5:1; the captions are harder to read than they should be

font_weight: 600 without a font prints a warning too: the bundled Inter has no semibold.

The output is still produced.

A line connector picks its colour as Placement with the line describes. With connector_style: "arrow", an arrow or stem is drawn solid in callout_background when a third or more of the pixels under it are dark (luma under 110), so it reads as the callout’s tail. Where a tenth or more of the pixels under it are as light as that fill, it keeps the dark ink with a 2 px white outline instead. A light connector colour (a light callout_border_color) mirrors this: over pixels with luma 145 or more it takes a dark callout_background, or else gets a dark outline. A burn without a theme draws near-black ink (#1c1c1c) and adds the 2 px white outline over dark pixels.

burnWithWorkspaceTheme reads the theme from <workspace>/.docsxai.json and burns with it:

import { burnWithWorkspaceTheme } from "@docsxai/viewer";
const png = await burnWithWorkspaceTheme({
workspace: "/path/to/workspace",
screenshotBuffer,
annotations,
});

It returns what burnAnnotations returns and forwards options to it; an explicit options.theme wins over the workspace’s. The theme is loaded once per workspace and kept for the process, keyed by the workspace path and the mtime of .docsxai.json (a changed tokens or font file needs a touch of the config). Contrast and font weight warnings go to options.warn (default console.warn) once per load. An invalid block throws a BurnThemeError with the file and the key list.

docsxai render appends one :root rule to each flow page. The stylesheet reads every variable with today’s value as the fallback, so a page built without a theme looks as it did.

VariableTheme value
--sd-accent, --sd-accent-glow, --sd-accent-pulseaccent, and the same colour at 35% and 30% opacity
--sd-halo-radius4/7 of radius
--sd-badge-fill, --sd-badge-radiusbadge_fill, and the radius of badge_shape
--sd-callout-bg, --sd-callout-inkcallout_background, ink
--sd-callout-border, --sd-callout-radiuscallout_border_width and callout_border_color, radius
--sd-callout-shadow, --sd-callout-paddingshadow, padding_y and padding_x
--sd-callout-font-size, --sd-callout-line-heightfont_size, line_height
--sd-connectorarrow colour: callout_border_color, or ink without an outline
--sd-fontthe embedded font, when font is set

Without a theme the live callout keeps its dark fill and white text while the burned one is white with dark text. Setting any burn.theme makes both use the theme’s callout_background and ink.

A block is checked as a whole and every problem is listed with its key:

burn: .docsxai.json burn.theme has 3 problems:
- accent: "red" is not a colour; use #rgb or #rrggbb
- radius: 40 is outside the range 0 to 24
- calloutBackground: not a known key (did you mean "callout_background"?); keys are preset, from, tokens, accent, ...

The command exits 1 and writes nothing.

  • Before and after images. They need a CI artifact step; none exists, so this page shows none.
  • Per-flow themes: one theme applies to the whole workspace.
Made by Kalebtec·GitHub·Apache-2.0 licensed