---
title: "Styling — quario"
description: "The closed, target-neutral style vocabulary a quario definition draws from, how it layers, and how a target draws the box."
url: "https://getquario.com/docs/diving-deeper/styling"
---

# 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](https://getquario.com/docs/reference/style-declarations.md). 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.

```json title="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`.

```json title="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.

```json title="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.

```json title="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.

```json title="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](https://getquario.com/docs/diving-deeper/security.md) 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.

```ts title="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.

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Docs","item":"https://getquario.com/docs"},{"@type":"ListItem","position":3,"name":"Styling","item":"https://getquario.com/docs/diving-deeper/styling"}]}
```
