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.
{ "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.
{
"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.
{
"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.
{ "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.
{ "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.
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.