Escape
HomeDocsStyling

Styling

Intent

A definition styles itself in one closed vocabulary that belongs to no output format. Each target maps that vocabulary to its own model. That’s what lets a single document look like itself on a page, in a PDF, and in a workbook. The HTML target’s inline CSS is one such mapping, not the schema’s native tongue.

Design decisions

The vocabulary is a closed set, and the names aren’t CSS. They come from the banded-reporting tradition. An unknown name is a definition error rather than a declaration nothing reads, so a typo fails at compile time instead of rendering silently unstyled.

A declaration a target can’t honour is withdrawn, not invented. The XLSX target doesn’t read uppercase, because a spreadsheet font carries no text transform and writing capitals into the cell would turn presentation into data. The cell would stop round-tripping and would sort differently. The CSV target reads no styles at all.

Every value may be an expression. A literal that’s wrong is a definition error, while an expression result that’s wrong stays lenient at render and contributes nothing. That split runs through the whole vocabulary.

Style layers in five, outermost in. The target’s own baseline, then the report default, then the band-role default a target supplies for headline roles. Then the node’s own style, and last a styled run’s. A run is the innermost layer, and everything that layers elsewhere layers there. A report declaring a size therefore doesn’t resize a report header, whose band-role default sits above the document’s.

The report default is a layer, not a merge. It’s narrowed to family and size. Every other name is a definition error there.

A run carries the inline half of the vocabulary, and nothing else. family, size, bold, italic, underline, strikethrough, color, background, uppercase, format and currency. The box and flow spacing describe a block, and align describes a line. Any of them on a run is a definition error naming the run.

A family name is an intent the host resolves. sans, serif and mono are the three every target resolves unaided. Any other name is a real typeface, and a name isn’t a face. The HTML and PDF targets each take a font mapping saying what it means on that surface. A generic name is the portable declaration.

The box is border-box, per side, and doesn’t collapse. A border side is width, style and colour together, or none. Adjacent edges stay separate.

A table row’s style resolves onto that row’s cells. A row isn’t a container that can hold a box on any surface. Not an HTML <tr>, not a worksheet row, not a band of the page. So the engine resolves the row’s declarations onto its cells before any target sees them.

API walkthrough

The full list of names is in the style declarations reference. This page covers the parts whose behaviour isn’t obvious from the name.

Setting a document default

style on the report states the typeface the document sets itself in. A document with one face names it once instead of on every item, column, and cell.

the report default
{ "style": { "family": "sans", "size": 10 } }

It’s resolved once per render, in report scope. It carries family and size and nothing else.

Drawing a rule with the box

A definition declares padding and border per side, as padding<Side> and border<Side>Width / border<Side>Style / border<Side>Color.

a grand-total rule
{
  "style": {
    "bold": true,
    "paddingTop": 5,
    "borderTopWidth": 0.5,
    "borderTopStyle": "solid",
    "borderTopColor": "#1e1f22"
  }
}

An incomplete literal border is a definition error, so a side can’t be half declared. A side that still holds an expression defers to render, where an incomplete result contributes nothing rather than becoming a solid black stroke. Width 0 with a style and a colour is complete and draws nothing, and a named 0 beats whatever cell padding a target would otherwise supply.

A column’s width percentage is the border-box share, so padding and border eat the wrapping width rather than adding to it.

Styling a whole row at once

A row’s style carries every declaration the vocabulary has, and the engine resolves it onto that row’s cells. One declaration therefore draws the rule under the column labels, or sets the pitch of the data rows, instead of one per cell.

a rule under the column headers
{
  "detail": {
    "header": {
      "style": { "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" }
    },
    "columns": [{ "header": { "value": "Item" }, "value": "{{ @.item }}" }]
  }
}

The consequences follow from the cells being the box. A row’s borderBottom is one continuous edge under the row, because each covered cell draws its own. A row’s borderLeft is an edge on every cell rather than one at the row’s outer left. The row’s own left edge is a borderLeft on the first column’s cells.

This is where a row differs from a split, whose box stays its own.

Styling part of a line

A cell value may be a list of runs, each a { "value", "style" } pair. That’s how you bold a word or format an amount inside a sentence. It survives into the PDF and the workbook, where <b> in a template only ever meant something to HTML.

a bold, formatted amount inside a sentence
{ "value": [{ "value": "Paid " }, { "value": "{{ @.amount }}", "style": { "bold": true, "format": "currency" } }] }

A target may merge adjacent runs styled alike, and a single unstyled run is the same document as a plain string.

Aligning within a box

align is horizontal. valign is vertical, and it’s legal exactly where a box has height it didn’t ask for. That means a table cell inside its row and a split slot inside its split. On a stacked text item, a band image or the report default it’s a definition error, because there is no slack to read.

The word is middle, not center. That one is already the horizontal name.

Naming a typeface

family takes sans, serif or mono, or a name the host maps.

mapping a family on each surface
import { html } from '@quario/html'

const target = html({ fonts: { Inter: 'var(--font-inter)' } })

The PDF target maps the same name to TrueType bytes instead. Text that declares no family renders in the report default’s face, and in the target’s own sans where the report declares none.

The two mappings differ in reach. The HTML target’s resolves names a report declared, and never the face of text that declared nothing. The PDF target’s also replaces a generic’s base-14 faces, so mapping mono there reaches text that named no typeface at all.

Showing and hiding

visible takes an expression, evaluated against the same scope the item’s templates read. A hidden table cell keeps its column slot, so suppressing a value leaves the column aligned.

A cell that’s empty or only whitespace contributes padding and no blank line. A hidden cell and an empty visible one stay the same height.

One vocabulary, never CSS names.

npm install quario
Getting started
© 2026 quario · KvK 61815977