Escape

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 target withdraws a declaration it can’t honour rather than invent one. 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 split’s style is a node’s own for every slot under it, so it outranks the band-role default as well. A run is the innermost layer, and everything that layers elsewhere layers there. The report header’s band-role size is a factor rather than a fixed point: 1.4× the size the document declares. A document setting "size": 20 renders its headline at 28, still above its own body. An item declaring a size replaces the factor outright.

The report default is a layer, not a merge. It holds only 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, currency and href. 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 } }

The engine resolves it 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. A target never merges two runs pointing at different places, because a link needs a rectangle of its own.

Linking a cell or a run

href says where text points: a URL, or # and a group’s label for a destination inside the document. It sits on a cell and in a run’s inline subset. Linking three words of a sentence is therefore the ordinary spelling, and it needs no item type of its own. A table row refuses it, the way it refuses every declaration that presents a value rather than describing a band of cells.

a link on part of a line
{ "value": [{ "value": "See " }, { "value": "the terms", "style": { "href": "https://example.com/terms" } }] }

The engine admits a URL before it reaches a target, against an allowlist the host owns: quario({ schemes }), defaulting to https: and mailto:. The engine drops a refused value, so the text renders and the link doesn’t. A URL arrives from data, and failing the whole render the day a row changed would be the wrong trade. Security carries why this is the one declaration the engine judges rather than passes on.

Targets differ in what they make of it. HTML wraps the run in an <a>, PDF draws a link annotation over its rectangles, and Word writes a hyperlink. A worksheet approximates. A cell carries one hyperlink and a rich-text run carries none. So a cell links only where all of its text points at one place. CSV withdraws it with the rest of the style.

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.