Quill.yaml Reference¶
Complete reference for authoring Quill.yaml configuration files. For a hands-on introduction, see Creating Quills.
File Structure¶
A Quill.yaml has these top-level sections:
quill: # Required: quill metadata
...
main: # Optional, main entry-point card: field schemas and optional title/ui/body
title: # optional label for the card
fields:
...
ui: # optional UI hints
body: # optional body-region config (enabled, description, example)
card_kinds: # Optional: additional composable card kinds
...
typst: # Optional: backend-specific configuration
...
Root-level fields: is not supported; define the main document's field schemas under main.fields.
Quill.yaml is parsed strictly. Unknown keys in the quill: section or the backend's section, unknown top-level sections, malformed ui: blocks, and field schemas that can't be parsed all produce errors: they are never silently dropped. Every error is collected in a single pass, so authors see all problems at once; the one exception is a requires floor above the running Quillmark release, which is reported alone. Run quillmark validate <quill_dir> to surface them.
quill Section¶
Every Quill.yaml must have a quill section with the quill's metadata.
quill.name must be snake_case (^[a-z][a-z0-9_]*$), at most 64 characters.
| Key | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Unique identifier for the quill |
backend |
string | yes | Rendering backend (e.g. typst) |
description |
string | yes | Human-readable description of the quill package, for a gallery or a quill picker (non-empty), and the title of a Typst PDF whose render and plate name none. The authoring schema never carries it: the document's label is main's own title and description. |
version |
string | yes | MAJOR.MINOR.PATCH, bumped by what the change costs a stored document (Quill Versioning) |
author |
string | no | Creator of the quill (defaults to "Unknown") |
license |
string | no | The quill's license, as an SPDX license expression |
homepage |
string | no | Where the quill's source or docs live, as an absolute http or https URL |
ui |
object | no | Document-level UI metadata |
requires |
string | no | The oldest Quillmark this quill loads on, as ">=MAJOR.MINOR" or ">=MAJOR.MINOR.PATCH" |
requires is quoted: a bare > opens a YAML folded scalar. A Quillmark release older
than it refuses the quill with one quill::requires_unmet error, ahead of every
other check, so keys it has not learned yet do not read as typos; a release from
before requires existed reports requires itself as quill::unknown_key. Any
other spelling, whitespace included, is quill::invalid_requires.
license is an SPDX license expression on one line, such as MIT,
Apache-2.0 OR MIT or GPL-3.0-or-later WITH Classpath-exception-2.0. Each id
is spelled as the SPDX License List spells it, and
the operators AND, OR and WITH are uppercase. A license off that list is
LicenseRef-<name>. Anything else fails the load with quill::invalid_license:
/ for OR, NOASSERTION, a line break, or a value that is not a string. The
hint names the fix where one is plain, such as GPL-2.0-or-later for
GPL-2.0+. A deprecated id such as GPL-3.0 loads, with a
quill::deprecated_license warning. Spaces around the expression are dropped.
homepage is an absolute http or https URL naming a host, such as
https://github.com/org/quills. A user name or password before the host, a
port past 65535, and an invisible formatting character such as a zero-width
space each fail the load with quill::invalid_homepage, as anything else does.
Neither key reaches the plate or the authoring schema; both surface on
Quill.metadata and quillmark info. They arrive in the same release as
requires, so an older Quillmark reports them as quill::unknown_key whether or
not the quill sets requires.
A backend's own settings live under its backend-named section, not in quill:.
The Typst plate's path, for example, is typst.plate_file, which defaults to
plate.typ (see the typst Section below).
quill:
name: usaf_memo
version: "0.1.0"
backend: typst
description: Typesetted USAF Official Memorandum
author: TongueToQuill
Example document¶
A quill's example document shows it filled in. It is derived from Quill.yaml: each field's example and each body's body.example, committed over the seeded document (Quill.example_document()). Its values are made up and nobody keeps them. Starter content people keep is a template, not an example.
- A field without an
exampleis left out and renders its default. A typed table without one gets one row of its columns' examples, a matrix its first member with its columns' examples, and avariants:enum the world itsexamplenames. - An
exampleoutranks a card kind'sseedwhere both name a field. - A quill declaring no example has no example document. A file named
example.mdis an ordinary file. quillmark validaterenders it after the empty document and the seeded document, and any diagnostic on it fails the quill, a warning included.
main Section¶
The main document card holds root-block field schemas under main.fields. Optional main.description describes the root card, the authoring schema's own description (quill.description describes the package and never appears there), and optional main.title labels the card as a card kind's title does. Optional main.ui sets container-level UI for that card. quill.ui is a fallback for main.ui, not a merge: any main.ui (even an empty ui: {}) wins wholesale, and quill.ui applies only when main.ui is absent.
Field order under main.fields is display order in UIs: the declaration order of the keys is carried structurally through parsing and schema emission, so consumers walk the fields in key order. There is no ui.order knob: to reorder fields, reorder them in Quill.yaml. Related fields group under a top-level object (see Grouping).
Field keys must be snake_case (^[a-z][a-z0-9_]*$). Capitalized field keys are reserved, and body is refused (quill::reserved_field_name): it is the address of the card's own body.
A card declares at most 1000 fields — the width one card-yaml block carries — and past it the quill fails to load (quill::too_many_fields). The count is per card, and only its top-level keys are charged: a nested properties map, an array's items, and a variants: cell set all ride inside the one field that declares them.
main:
fields:
subject: # Field name (used as the card-yaml payload key)
type: text
description: Be brief and clear.
Field Properties¶
| Property | Type | Required | Description |
|---|---|---|---|
type |
string | yes | Data type (see Field Types); a trailing ? makes the field optional |
title |
string | no | The field's label, a literal (see title) |
description |
string | no | Help text for the writer, carried by the authoring schema as the field's description |
default |
matches type |
no | The value the majority of authors want. When the value position is omitted, the default is filled in — at any depth, whether or not the container above it was authored — and the authoring schema carries it as the field's default (see default). Declared on a value position: a leaf, an array or a matrix. On an object it is a load error (quill::default_on_namespace), since its properties hold their own. |
example |
matches type |
no | A made-up value, checked at load as default is: it answers nothing, schema() exposes it, and the example document commits it (see example). On an object it is a load error (quill::example_on_namespace). |
values |
array of strings | for enum |
The closed set of allowed string values: the choices. Required on every enum field. Declaring "" is a load error — every enum also accepts its blank, which Quillmark supplies. |
ui |
object | no | UI rendering hints (see UI Properties) |
items |
object | for array |
Element schema for an array field (a nested field schema). Required on every array. |
properties |
object | for object |
Nested field schemas for an object typed dictionary (or an array's object-typed items). Required on every object field. |
multiline |
boolean | for richtext |
For text and richtext only: true admits more than one line (text) or block structure (richtext). Absent or false, the field is one line or one paragraph. A text value's trailing whitespace drops before the check, the newline a YAML | block keeps included. A write refuses a line break inside a text value. A stored one renders as a space and warns validation::line_break_folded. A richtext declares it either way. |
title¶
The label an editor shows beside the input, and the name a person uses for the
field ("set the point of contact"). Absent, consumers humanize the key
(memo_for → "Memo For"), so declare it only where that reads wrong:
main:
fields:
memo_for:
type: array
title: To # "Memo For" would confuse users unfamiliar with memo conventions
items:
type: text
The authoring schema carries it as the field's title, beside its
description, so an agent reading the schema knows the field by the name a
person says.
It is a literal. A {field} token in it is a load error
(quill::title_template): nothing interpolates it. An array's items takes
none (quill::title_on_items): the array's own title names the list.
default¶
One question per field: does it have a value when nobody types anything?
default is the answer, and the only value a field declares.
# The value most authors want.
closing:
type: text
default: Sincerely
# No default: the value position stays empty until an author answers it.
classification:
type: enum
values: [UNCLASSIFIED, CUI]
The authoring schema carries them as:
"closing": { "type": ["string", "null"], "default": "Sincerely" },
"classification": { "type": ["string", "null"], "enum": [null, "", "UNCLASSIFIED", "CUI"] }
A blank default: ("", [], {}) is legal and renders as no default does:
the field renders its blank either way.
The schema still carries it and resolve() still reports the field's source as
default, so an editor reading either may treat the two differently. 0 and
false are values, and print.
An unanswered field renders its default:, else its
blank, else
none where the type is optional.
A sample value is the field's example, not its default. A format hint is description text.
Nothing is required. There is no required: key: an unanswered field renders,
and Quill::validate reports nothing about it.
example¶
A made-up value showing what an answer looks like. It never answers a value position: an unanswered field still renders its default:, else its blank.
signer:
type: array
items: { type: text }
description: "Line 1: Name as signed, as in FIRST M. LAST, Capt, USAF. Line 2: Duty title."
example:
- JANE Q. DOE, Capt, USAF
- Chief, Knowledge Operations
- It is checked at load as
defaultis, underquill::example_*codes: its type, an enum'svalues, a date's format,max, and a richtext's import (quill::richtext_example_import). - It may sit beside a
default, and on an optional field. - It goes on any field at any depth: a dictionary's property, a table's column, a matrix's column, a variant's field. An
objecttakes none, its properties holding their own; avariants:enum's is the member alone. - A table's own
exampleis its rows; a matrix's is the members it holds. schema()carries it afterdefault, and the authoring schema as the field'sexamples.
Field Types¶
| Type | Notes |
|---|---|
enum |
A closed set of string values; requires a values: list. Also accepts its blank (""), which is not a declared member. The schema emits values: as declared, with no "" |
text |
Open, unformatted text over the canonical content: the same nav/regions as richtext, but a literal codec (delimiters stay literal, no markup). One line; add multiline: true to admit line breaks. Optional max:, a character cap (see max). A plate that computes with the value reads it as a str through qm.str |
number |
Numeric scalar (integers and decimals) |
integer |
Integer-only numeric scalar, sized as an i64; a literal past that range takes number |
boolean |
true or false |
array |
Ordered list; requires an items: element schema. Optional max:, the element count the page holds (see max) |
date |
A strict calendar date YYYY-MM-DD, or today (see Dated by the render); rejects any time component |
datetime |
A strict offset-less wall-clock datetime YYYY-MM-DDThh:mm[:ss]; rejects offsets, the space separator, fractional seconds, and bare dates |
richtext |
Rich, formatted prose over a canonical content; backends lower it to the target format. Markdown is its import/export projection. One paragraph; multiline: true admits paragraphs, lists and other blocks. Declare multiline: either way, true or false: leaving it out is a load error. Optional max:, a character cap |
object |
Structured map; requires a properties: map |
matrix |
A vocabulary the author ticks; requires a members: roster, and open: true lets a document add items. Its value is the set of held members, each an object of the field's properties: (see Matrix) |
Dated by the render: today¶
A certificate or a form issued on the spot is dated by the day it is rendered. A date value may be the word today, authored in a document or as a default::
issued:
type: date
default: today # every document leaving the field unset renders on its render date
The document stores today, not a date. The render supplies the date: the CLI and the WASM and Python bindings use the local date unless given one (quillmark render --today 2026-03-14, engine.render(quill, doc, opts, "2026-03-14"), engine.render(..., today=date(2026, 3, 14))). A plate's datetime.today() returns the same date. A document that writes a date instead pins it.
Optional fields: t?¶
An unanswered field reaches the plate as its type's blank: 0 for a number, false for a boolean. Those read as answers, so a plate cannot tell "0 votes against" from "nobody entered a tally". Append ? to the type where it must:
quorum:
type: integer? # none when unanswered, an integer otherwise
confidential:
type: boolean
default: false # always a boolean
| Declaration | Renders when unanswered | The plate reads |
|---|---|---|
type: t |
the type's blank | a t, but none for a date or datetime |
type: t with default: |
the default | a t, but none for a date or datetime defaulting to "" |
type: t? |
none |
a t or none |
- Any value position takes the
?: every scalar type,text,richtext,date,enum,arrayandmatrix. Anobjectand anenumwithvariants:do not (quill::optional_namespace); mark the fields inside them instead. ?anddefault:are exclusive (quill::optional_default): a default answers for the author, so the field would never benone.- An authored value is kept as written:
0,false,"", and[]are answers. On anenum?,""is the blank and readsnone.
Reading an optional field in a plate: Blank values.
Choosing among enum, text, and richtext¶
Two questions resolve it:
- Is the set of allowed values closed? Then it is an
enum, a stored id the plate branches on. - Otherwise the field is open text: should markdown delimiters format it (
richtext:*text*becomes emphasis) or stay literal (text:*text*stays four characters)?
Then decide the shape: text is one line and richtext one paragraph, and multiline: true admits more.
| Declaration | Holds |
|---|---|
{ type: text } |
one line, literal |
{ type: text, multiline: true } |
lines, literal |
{ type: richtext, multiline: false } |
one formatted paragraph |
{ type: richtext, multiline: true } |
formatted paragraphs, lists and other blocks |
A name, a URL or a reference key is text too: the plate prints it as content, and where it computes with it (set document(author: ..), upper, a comparison) it reads qm.str(..), the value's string (Text as a str).
Any field the plate prints through qm.ink, enum included, gets a region and click-to-edit in editor consumers (Print with ink). A content field rides the canonical content model and adds navigation inside its value: a click lands a caret in the text. text and richtext share that entire stack and the same backend lowering, so they are indistinguishable in an editor and diverge only at emit, where the codec decides whether a delimiter is markup or a character.
Changing a declared type reinterprets every stored value in that field at the next bound load, with no diagnostic, and text → richtext is the lossy direction: the stored string enters the markdown import and its delimiters are consumed as structure, leaving the literal characters unrecoverable. A declared type change is a new quill version (Quill Versioning); audit the corpus before publishing one, as under Date and Datetime Grammars.
Date and Datetime Grammars¶
The two grammars are disjoint: date rejects any time component, datetime rejects a bare date, and neither truncates. A field holding a mix of 2026-06-01 and 2026-06-01T09:30 has no correct declaration, since whichever type it takes strands the other half. Normalize the values, or split the field in two.
Coercion runs when the document lowers into the backend's data, upstream of the plate. A value outside its declared grammar fails the render before any template code executes, so a plate that never mentions the field fails identically and no plate-side coercion repairs it. quill.validate(doc) is what names the field and its path. A date or datetime field is a scalar, so quill.conform(doc) passes it in silence: the conform walk covers content fields only.
Changing a declared date type on a deployed corpus is a corpus operation, not a schema edit. The stored string is never rewritten: neither the transport input path nor conform touches a scalar. Nothing is lost, and every document holding a value the new grammar rejects strands at render. Audit before publishing the change: load each stored row through the transport input path, read the field, and test it against the target grammar. reader.get returns the stored string verbatim for both date types.
const doc = Document.fromStored(row);
const value = quill.reader(doc).get('issued');
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
// Strands under `type: date`. Repair before the schema change ships.
quill.writer(doc).set('issued', value.slice(0, 10));
}
writer.set refuses the same values the render does (edit::field_coercion_failed), so the repair writes the corrected string rather than the original.
Enum Constraints¶
Declare a closed string domain with type: enum and a required values: list:
main:
fields:
format:
type: enum
values:
- standard
- informal
- separate_page
default: standard
description: "Format style for the endorsement."
values: on any other type is a load error, as is the retired enum:
modifier on any type.
A value is an id: it is what a document stores and what the plate reads.
Renaming one stops every document holding it from validating
(validation::enum_violation; see Versioning). Spell it as a
stable token (separate_page, not Separate page). A Typst plate prints
display text by mapping each id to its words; a PDF-form dropdown and an
editor's picker list the ids themselves. Where the text is itself the standard,
as a marking such as UNCLASSIFIED is, the id is that text.
The blank: values: is for choices, not for the absence of one¶
Every enum accepts one value beyond its values: list — the blank, spelled
"". Quillmark supplies it; you never declare it, and declaring "" in
values: is a load error (quill::enum_blank_member).
That is what a document says when nobody has answered. A field with no default:
renders its blank rather than the first variant, so reordering values: never
changes what an unanswered document renders, and no reader ever sees a choice
nobody made.
The two keys range over different sets, which is why "" is rejected in one and
accepted in the other:
classification:
type: enum
values: [UNCLASSIFIED, CUI, SECRET] # the choices — no "" here
default: "" # the blank — a value, and legal
values: enumerates choices. default:, your documents, and the projections
all range over values ∪ blank. default: "" renders as no default does;
whether an author should answer the field is its description's to say.
Where the empty state is itself a decision someone makes and the document should
record it, make it a member — undecided, waived, n_a — not the blank. The
blank means nobody chose; a member means someone chose "none".
Name the blank's label with ui.blank_title when a bare empty row would read
badly; absent one, consumers supply their own conventional label.
If you arrive from web forms, your prior transfers: this is HTML's
placeholder <option value="">, Django's ("", "---------"), Rails'
include_blank:. One caveat — the affordance carries over, the enforcement does
not. There is no required:; an unanswered field draws no diagnostic and
renders. If you arrive from protobuf, your
prior is a near-miss: proto3 reserves slot 0 inside the enum
(FOO_UNSPECIFIED = 0), whereas here the sentinel lives outside the domain and
your values: list stays clean.
Writing a plate against an enum: branch over
values ∪ blankexhaustively. Anelsefallback silently renders a variant nobody chose, andqm.data.at(key, default: X)is not a guard — every declared key is always present at render, so itsdefault:never fires and the blank flows through.
Variants: fields that exist only for one choice¶
Some fields belong to one choice and are meaningless beside any other. Declare
them under variants:, keyed by the member that brings them into play:
classification:
type: enum
values: [UNCLASSIFIED, CUI, CONFIDENTIAL, SECRET, TOP SECRET]
default: ""
variants:
CUI:
controlled_by: { type: text } # exists only on a CUI memo
poc: { type: text }
category: { type: text, default: "" }
The field then holds a container instead of a bare string, and a document
writes the choice under value with that world's answers beside it:
A world with nothing to fill in still writes plainly — classification:
UNCLASSIFIED is accepted and means the same as {value: UNCLASSIFIED}. A value position
written beside the field instead of under it (poc: at card level next to
classification: CUI) is an undeclared key that no declared field reads, and
quill.validate(doc) warns validation::unknown_field with a hint to nest it.
The container is a document shape. The schema's own default: names the
discriminant alone (default: "", default: CUI); a container-shaped one is a
load error, because each value position in a world carries its literal on its own
declaration.
Two things follow, and they are the reason to reach for this over a
cui_-prefixed row of flat fields:
- The names shorten. The prefix was hand-written namespacing; nesting
supplies it structurally, so
cui_pocbecomespoc. - Editors know. The schema says which value positions are out of play, so a form retires them instead of showing a CUI block on an unclassified memo.
Writing a plate against variants. The live world's fields arrive only inside the branch that selects it, which is the branch you already owe every enum:
..if qm.data.classification.value == "CUI" {
(controlled_by: qm.data.classification.controlled_by, poc: qm.data.classification.poc)
},
Inside that branch every declared field of the world is present and blank-filled,
so no guarded access is needed. Outside it there is nothing to read: an
unanswered classification renders {value: ""}, and the blank brings no field
set at all.
Flipping the choice keeps the answers. Selecting UNCLASSIFIED after filling
in a CUI block leaves those values in the document and warns
(validation::out_of_variant); they simply stop rendering. Flip back and they
are still there. Remove the field to drop the value for good.
A variant value position is an ordinary field: any type a card field may carry, prose,
dates and containers included, a typed dictionary (type: object) among them,
reaching the plate exactly as a card-level one
does. What it cannot carry is variants: of its own. variants: itself
is valid on a type: enum field that is a card's own field or a typed
dictionary's property — header.classification reads, binds and branches as
classification does, one step down — and nowhere else: an array element, a
matrix column and another variant's value position each refuse one, as does an object
inside any of them. It
keys only on declared members (never "", which owns no field set), and cannot
declare a field named value. Variant fields sit beside the discriminant,
inside its group object when it is grouped; a ui.group on a
variant field is quill::nested_group_not_supported. A field set shared by
several members is repeated or shared with a YAML anchor — a variant keys on one
member — but every variant declaring a given name must declare it identically,
since the name is one value position of the container whichever world brings it into play
(quill::variant_field_collision).
Primitive Arrays, Typed Tables, and Typed Dictionaries¶
Every array declares its element type under items:. For a primitive list, give items a scalar type, coercion and validation then apply element-wise (e.g. each element of an integer[] is coerced to an integer, and a bad element fails at its indexed path like counts[1]):
main:
fields:
tags:
type: array
items:
type: text
counts:
type: array
items:
type: integer
sections:
type: array
items:
type: richtext # each element's content is lowered to backend markup
multiline: true
For a typed table (a list of structured rows) give items an object type with its own properties:. Coercion recurses into each element and converts property values to their declared types:
main:
fields:
cells:
type: array
items:
type: object
properties:
category:
type: text
score:
type: number
A document may write a single value where an array is declared: it is a spelling of a one-element array, and the schema is what disambiguates it. tags: one renders as tags: [one] does, and quillmark check passes it. The element is then judged against items, so a mapping written for tags is a validation::type_mismatch at tags[0], while one written for cells is its one row. Likewise, a bare boolean, integer, or number written where text is declared adopts its canonical scalar token (true, 47, 1.0; 1.50 reads 1.5) instead of failing.
Use type: object with properties: for a single structured mapping:
Containers nest freely: a property or an element is an ordinary field, so it carries whatever type a card-level field carries, itself included. object<array<text>>, array<array<integer>> and a typed table whose row holds a typed dictionary are all declarable, and each leaf is addressable by the schema address its path spells (contact.address.city, grid.0.0 — see PLATE_DATA.md).
A dictionary holds no default: of its own — its properties do:
address:
type: object
# default: { street: "5000 Forbes Ave" } # quill::default_on_namespace
properties:
street: { type: text, default: "5000 Forbes Ave" }
city: { type: text }
The container spelling would be a second declaration of a value the property
already holds, free to disagree with it. Each property's default: is reached whether or not a document authors the container
above it, so writing address: {} changes nothing.
An array does keep its own default:: items: fixes the
element type but never the arity, so default: [{…}] says something no
property declaration can. Each element it supplies is completed
against items exactly as an authored element is.
A legacy ui.group holds card-level fields only: an object's properties and an array's items cannot join one. variants: reaches one step further — a card field or a typed dictionary's property carries one, and an array element, a matrix column and another variant's cell do not (see Variants).
max: what the page holds¶
A form laid out on a page holds a fixed number of rows. Say so with max:, and
the limit stops being prose only a human reads:
experience:
type: array
max: 37 # the form's 37 ruled lines
items:
type: object
properties:
duty: { type: text, default: "" }
max: is a non-negative integer counting what the field holds, in the field's
own unit: elements on an array, characters on the text types (below). It is
never a bound on a number's value. A default: longer than it fails to load. A document holding more elements draws
a warning, validation::cardinality, at the field's path with max and
actual in its args — never an error: the document still renders, and the plate
decides what happens to the surplus. Each declaration carries its own cap, so a
capped array nested inside a row is checked against its own.
The authoring schema carries the cap as quillmark:maxItems, an annotation
rather than JSON Schema's asserting maxItems, since the cap never refuses.
A text or richtext field takes max: too, counting characters: a record
limit, or a box printed for a fixed number of them.
citation:
type: richtext
multiline: true
max: 1350 # the record system's limit
statements:
type: array
max: 4
items: { type: text, max: 350 } # each statement
A character is a Unicode scalar value of the plain text: a richtext's markup
does not count, and neither does a table or an image; a line break counts as
one, and trailing line breaks, or a one-line text's trailing spaces, do not
count. An unquoted number counts as its text, so ssn: 1234567890 is ten
characters. A longer value draws a warning,
validation::over_length, at its own path with max and actual, and renders
as written; a default: holding a value longer than its cap, at any depth, or a kind's seed: fails to load
(quill::default_over_length, quill::seed_over_length).
The authoring schema carries it as quillmark:maxLength. On an acroform
quill the cap is the widget's /MaxLen.
There is no min:. min: 1 would be required: under another name, and no
field is required (default); it would
also contradict a sibling default: [].
Matrix: a vocabulary the author ticks¶
Some fields are neither a list the author grows nor a choice they make once:
they are a fixed vocabulary printed in full, where the author ticks what they
hold and may annotate a tick. type: matrix is that shape.
qualifications:
type: matrix
description: Tick each qualification held.
members: # ordered; `id: Title`
sq_cc_candidate: Sq/CC Candidate
flight_cc: Flight CC
dodin_ops: DODIN Ops
properties: # the columns; omit them for a plain checklist
detail:
type: text
default: ""
Member ids are snake_case: they are what the wire, the address and the
document speak, while the title is display only. The roster is a mapping,
so it has one key per id and a member cannot be declared twice. held and
title cannot be column names.
A document ticks sparsely. A member is held by being present:
qualifications:
flight_cc: true # held; columns take their defaults
dodin_ops: { detail: "2024" } # held, with columns
sq_cc_candidate: false # not held, as if absent
A mapping is held whatever it carries, {} included, and a bare scalar is the
tick. The document never stores held: a mapping carrying one fails
validation (validation::held_stored), so untick a member by removing it, and
its answers go with it.
A member the roster does not declare is refused, as an out-of-domain enum
value is. A mapping has one entry per key, so a member cannot be ticked twice.
The plate receives the held set: each held member's columns, keyed by id, in
roster order. A member the document does not tick is absent, so print the
vocabulary through qm.roster, which hands your plate every member, held or not,
without a copy of the roster of its own:
#import "@local/quillmark-helper:0.2.0" as qm
#for row in qm.roster(qm.data, "qualifications") {
qm.region(row.path)[#(if row.held [☒] else [☐]) #row.title]
if row.held [ — #qm.ink(row.value, "detail")]
}
Each row is (id, title, held, value, path): value is the member's columns,
none where unheld, and path its address. Pass the dictionary that declares
the matrix and its name: qm.roster(card, "qualifications") on a card,
qm.roster(row, "quals") in a table row.
Three things to author against. Declare a default: on each column to say
what a ticked member's unanswered column renders; without one it renders the
column's blank. The matrix is a value position, so a default: on it is the set a
document that leaves it out holds, and type: matrix? renders an unanswered one
none; without either it renders {}, nothing ticked. A fresh document ticks
nothing, and the authoring schema shows the vocabulary as the matrix's
properties, one per member, and its roster as quillmark:roster. And each member is an
address — qualifications.flight_cc is its tick, a region on Typst and a
checkbox on acroform checked by its presence, and
qualifications.flight_cc.detail its column.
Open rosters: items the author adds¶
A record rarely fits a fixed vocabulary. open: true lets a document add items
beside the roster, each keyed by a snake_case id of the author's choosing and
carrying a title:
qualifications:
type: matrix
open: true
members: { flight_cc: Flight CC, dodin_ops: DODIN Ops }
properties:
detail: { type: text, default: "" }
qualifications:
flight_cc: { detail: UMS Flt/CC }
wing_ig: { title: Wing IG, detail: 81 TRW } # an added item
- An added item is held by being present and takes the same columns a member
does. Its
titleis a one-linetext, and a key outside the roster with notitleor a blank one (""or whitespace alone), or one that is not a snake_case id, is refused (validation::enum_violation, hinted toward the added spelling). - The plate receives the held roster members in roster order, then each added
item sorted by id,
{title, …columns}, andqm.rosterreturns them in that order. A plate wanting another order sorts the rows itself. An added item'stitleis a value position with an address, soqm.ink(row.value, "title")prints it with its click-to-edit region, whererow.titleprints it plainly. - Quillmark does not compare titles: an added item titled as a roster member prints beside it.
- The ids in the authoring schema's matrix properties are all a model sees of
the roster, so on an open matrix name them as their titles read
(
joint_qualified_officer, notjqo), or a model adds a duplicate it could have ticked. - An id your roster later declares reads as that member, so an item authors
added under it becomes the member with no migration; its stored
titlethen warns asvalidation::unknown_fielduntil removed. opendefaults tofalse, and the acroform backend refuses it at load (quill::open_matrix_unsupported): a form has no widget for an item a document adds.
Full model: SCHEMAS.md; the wire shape is PLATE_DATA.md.
Grouping¶
Related fields group under a top-level object. Its title and description label the group, which an editor frames and the authoring schema carries:
main:
fields:
subject:
type: text
addressing:
type: object
title: Addressing
description: Who the memo is for and from.
properties:
memo_for:
type: array
items: { type: text }
memo_from:
type: array
items: { type: text }
default: []
letterhead:
type: object
title: "Letterhead & Seal"
properties:
letterhead_title:
type: text
A group is data. The document nests its keys under the group's id, and a plate reads data.addressing.memo_for:
~~~
$quill: memo@1.0
subject: Annual Records Management Review
addressing:
memo_for: [123 OG/CC, 123 MXG/CC]
~~~
So moving a field between groups is an address change, a MINOR bump like a rename. Fields beside the objects stay ungrouped, and an editor draws them above every group: one declared after a top-level object warns quill::field_after_object, since the order it was declared in is not the order it draws in. Nothing requires grouping: a flat questionnaire stays a flat fields: map. A card has no sections: key; one is quill::invalid_card_schema, its hint naming this form.
Legacy ui.group¶
The older spelling still loads: a ui.groups registry on the card, and a ui.group reference on each field naming its entry.
main:
ui:
groups:
addressing: {}
letterhead: { title: "Letterhead & Seal" }
fields:
memo_for:
type: array
items: { type: text }
ui: { group: addressing }
letterhead_title:
type: text
ui: { group: letterhead }
A group here is a frame, not data: the document stores memo_for: and a plate reads data.memo_for. It warns quill::deprecated_groups and loads with ungrouped fields first, then each group's fields in registry order, whatever order they were declared in. To rewrite it as Grouping, make each group an object keyed by its id, in registry order, carrying the registry title:; move each grouped field into its properties; and drop ui.group and ui.groups.
A group holding one object and nothing else needs no wrapper: that object carries the group's title: and stands at the top level itself, its value and addresses unchanged. Documents then nest each group's keys under its id, and plates gain the one path segment.
The registry's own checks still apply to the legacy spelling: a reference the registry does not declare (quill::unknown_group), a reference with no registry (quill::implicit_group), a repeated id (quill::duplicate_group), an id that is not snake_case (quill::invalid_group_id), and a ui.group below card level (quill::nested_group_not_supported). The registry is also read from quill.ui when main.ui is absent.
UI Properties¶
The ui property on fields controls how form builders and wizards render the field. These are UI hints, not validation constraints.
ui keys never reach the authoring schema, so a fact a writer needs belongs in
description or title. A field's label is its title;
ui holds none, and ui: { title: … } is a load error whose hint names it.
Nor does ui carry a field's line shape: that is the field's own multiline:,
which validation, the editor and a PDF form all read, and ui: { multiline: … }
is a load error.
field order¶
Field display order is declaration order: the order the keys appear in Quill.yaml. This holds at every level: card-level fields, and the properties of a typed dictionary or typed-table row. The order is carried structurally (the schema's field maps preserve key order, and schema() re-emits that order), so no per-field knob is involved.
There is no ui.order key: an authored ui: { order: N } is a load error (quill::field_parse_error). To move a field, move its block in Quill.yaml.
blank_title¶
Labels an enum's blank option in a picker. Absent, consumers render a conventional label of their own, so this is only worth setting when a bare empty row would read badly:
main:
fields:
classification:
type: enum
values: [UNCLASSIFIED, CUI, SECRET]
default: ""
ui:
blank_title: "(no marking)"
It labels the blank, never a member: values: carries no entry for it. Consumers must keep unset selectable and re-selectable — returning to it is how an author clears a value position, so a disabled placeholder that vanishes once a choice is made is the wrong idiom. Unset and a stored "" differ where default: names a member: the stored blank is an answer and outranks the default.
compact¶
Asks an editor to set a card-level field beside its neighbours in one row, where it would take a row of its own. Absent, the field's type decides:
| Packs by default | Full row by default |
|---|---|
date, datetime, enum (without variants:), integer, number, boolean |
text, richtext, array, object, matrix |
A text field packs only under compact: true, since its type cannot say whether a value is a rank or a subject line; compact: false keeps a packing type on its own row.
It is a request: an editor grants it only where a neighbour packs too, so a packing field alone between full-row fields still takes its row.
- An
array, anobject, anenumwithvariants:and amultiline: truetextorrichtextnever pack, whatever the key says, since their height grows with the document. - On a
matrix,compact: truesets the roster's members side by side instead. - The properties of a card-level
object, and of anobjectnested in it, follow the same rule, so a field moved into a group keeps its row. - The value positions of an array's records, a variant, a matrix member and a typed table are laid out by their container and read no
compact.
layout¶
Names the control a field asks an editor to draw, where the shape admits more
than one and the default reads wrong. table is valid only on a typed
table — an array whose items is an object:
main:
fields:
tours:
type: array
ui:
layout: table # a grid: one row per element, one column per property
items:
type: object
properties:
unit: { type: text, default: "" }
duration: { type: integer, default: 0 }
Without it, an editor draws a typed table as a stack of collapsed rows that open one at a time — right for a row of long prose, wrong for four short cells.
Declaring it says two things.
A contract: every column is a leaf. A column holding an array, an object
or a matrix addresses below itself and is not one value position, so it is a load
error naming the column — quill::table_column_not_leaf on
appendices[].entries. Text is a column whatever its multiline: how tall a cell
renders is the editor's call, what it contains is not. On any field that is not a
typed table at all, the key is quill::invalid_ui.
A request: draw it as a grid. An editor may still decline — a width that will
not hold the columns, a cell renderer that will not take a block richtext — and
fall back to the record list. What it will not meet is a shape it cannot draw at
all; the contract decided that at load. Nothing else reads the key: the plate,
document validation and the authoring schema are all deliberately inert on it, and schema()
echoes it verbatim for the editor to find.
flat is deprecated and warns quill::deprecated_flat, naming the field: an
editor no longer honors it. A card's top-level object is already a panel whose
properties stand flat inside it, and a nested one draws as a subform. It still
loads where it loaded, an object other than an array's element, and is
quill::invalid_ui anywhere else.
A row's label¶
A consumer labels a collapsed row from its own values, the first property
leading, so a list of rows reads as a list of things rather than "Item 1, Item
2". Declare first the property that names the row. items takes no title.
card_kinds Section¶
card_kinds define composable, repeatable content blocks (the kinds: a document can then carry zero or more instances of each kind, interleaved with body content). Each entry is shaped exactly like main: (fields, optional title, description, ui, body); think of main: as the single mandatory card-kind for the document body, and card_kinds: as the library of additional kinds that may attach to it.
Card-kind names (the keys under card_kinds) must match [a-z_][a-z0-9_]* (leading underscore is allowed). main is reserved for the document root.
card_kinds:
indorsement: # Card-kind name
description: Chain of routing endorsements.
fields:
format:
type: enum
values: [standard, informal, separate_page]
default: standard
from:
type: text
Invalid card-kind names include:
BadCard(uppercase letters)my-card(hyphen)2nd_card(starts with a digit)
When not to declare one¶
A card is a part someone writes; a repeated record someone fills in is a
row, and a row is an array<object> field on the card that owns it — not a
card kind. The test is a disjunction: a unit that stands in document order among
units of other kinds, or whose substance is flowing prose, is a card;
otherwise it is a row.
A kind declaring body.enabled: false draws a warning,
quill::bodiless_card_kind, pointing at the row shape. It is advice and not a
refusal: no body is only a proxy for "not prose", and a bodiless kind that truly
interleaves — a page break, a rule, an inserted signature block, all of which
exist for their position — is a card by the first clause, which no schema can
state. main is never warned: a form has no root prose.
Rows are cheaper where they fit. They nest lexically, so a row belongs to its
parent by position in the file rather than by counting cards in the stream, and
a plate reads card.rows instead of reassembling a run. What rows give up is
the fence-native body: a row's prose is a richtext cell, one quoted YAML
scalar, which is fine for a few lines and wrong for a memo's worth.
Card Properties¶
| Property | Type | Required | Description |
|---|---|---|---|
title |
string | no | The kind's label, a literal (see title) |
description |
string | no | Help text describing the card's purpose |
fields |
object | no | Field schemas (same structure as top-level fields) |
ui |
object | no | Container-level UI hints (see Card-level ui) |
body |
object | no | Body-region config (see Card-level body) |
seed |
object | no | What a new card of this kind starts with (see Card-level seed) |
title¶
The kind's label: what a picker, an add-card menu or a section header shows.
Absent, consumers humanize the kind name. The map key (e.g. indorsement) is
the $kind a document stores, so a title renames freely without invalidating
stored documents.
card_kinds:
indorsement:
title: Routing indorsement
description: Chain of routing endorsements.
fields:
from:
type: text
The authoring schema carries it as the kind's title under $defs, beside its
description.
It is a literal, naming the kind and never one instance: a {field} token is a
load error (quill::title_template). A consumer labels an instance from its own
values, so field order decides which value leads: declare first the field that
names the instance.
Card-level ui¶
| Property | Type | Description |
|---|---|---|
groups |
list or object | The legacy group registry (see Legacy ui.group) |
Card-level body¶
| Property | Type | Description |
|---|---|---|
enabled |
bool | Whether the body editor is enabled (default: true). When false, consumers must not accept or store body content for this card kind. |
description |
string | What the body holds, for the writer. |
example |
markdown | A made-up body, which the example document commits. It must import without dropping anything (quill::richtext_example_import). |
body.enabled¶
When false, the card kind has no body/content area, and an editor should offer none. The authoring schema gives the kind no body. A document that provides body content for such a card anyway still renders without it: the plate receives no $body, and quill.validate(doc) warns with validation::body_disabled.
card_kinds:
page_break:
body:
enabled: false # Card has fields only, no body/content area
fields:
style:
type: text
Loading this draws quill::bodiless_card_kind, which asks whether the kind is a card at all: a repeated record with no prose is a row (see When not to declare one). The example above stands because a page break exists for its position in the stream, which no row can occupy. main is never warned.
description and example describe a body, so either beside enabled: false is a load error (quill::invalid_body), as is any other key under body.
body.description and body.example¶
main:
body:
description: Plain paragraphs, numbered for you; a `-` item under one becomes a lettered subparagraph.
example: |
Each group reviews every office of record it owns by the suspense in the subject line.
- The file plan lists every series the office keeps.
schema() carries both, the example document commits the example as the body, and the authoring schema carries both on the body. An example is made up; starter text a new card keeps goes in its kind's seed, and for the document in a template's own body.
Card-level seed¶
seed is what seed_card writes into a new card of the kind: fields of the kind, plus an optional $body. It is the shape a document's $seed.<kind> takes, and a block copies unchanged between the two.
card_kinds:
indorsement:
seed:
signature_block:
- FIRST M. LAST, Rank, USAF
- Duty Title
$body: Write the indorsement here.
fields:
signature_block:
type: array
items: { type: text }
- A document's
$seed.<kind>replaces it whole,$bodyincluded: a template keeping part of the kind's seed copies that part, and$seed.<kind>: {}seeds nothing. - Once written, a seeded value is the card's own content: it prints, and a later change to
seeddoes not reach cards already made. What should render when nobody answers is adefault:instead, which tracks the quill. - A placeholder prints until someone replaces it. A format hint belongs in the field's
description:. - Each value must pass its field's checks,
$bodyneeds the body enabled, and a seed that renders as the unanswered field does (an emptyseed: {}, anull, a blank$body, the field'sdefault:, its blank when it has none, or{}for a typed dictionary) is refused (quill::seed_redundant). Each failure is a load error underquill::seed_*. maindeclares noseed.
Using Cards in Markdown¶
Card kinds defined here are authored as ~~~ blocks (with a $kind: <kind> line) in the document body. See card-yaml Blocks for the markdown syntax.
typst Section¶
The Typst backend compiles plate.typ at the quill root, found by name as Quill.yaml is. The optional typst section moves the plate elsewhere.
| Key | Type | Required | Description |
|---|---|---|---|
plate_file |
string | no | Path from the quill root to the Typst template the backend compiles, steps separated by /. Default plate.typ |
A quill holding no plate.typ and declaring no plate_file fails to render as typst::plate_missing, its hint naming both. Core reads no backend file at load: the backend resolves the plate when it opens a session, and quillmark validate checks it.
A . step or an empty one drops, so ./plate.typ names plate.typ and tpl//layout.typ names tpl/layout.typ, the path every diagnostic and quillmark workspace give. A leading / or a .. step is refused as typst::plate_missing, its hint naming the spelling from the quill root, and so is a plate_file naming no file. A plate_file naming a file at a path holding a \, which Typst refuses, fails as typst::plate_path_invalid: separate the path with /, and rename a file whose own name holds a \. So does a plate_file that is not a string, or that names no path once its . and empty steps drop, such as "" or ./.
Any other key under typst fails the load as quill::unknown_backend_key, its hint listing the valid keys. A typst section that is a scalar or a sequence fails as quill::invalid_backend_section, and an empty one, typst: alone or holding only comments, reads as no keys. A quill using a key a later release adds declares that release in requires, so an older Quillmark refuses the quill by its floor rather than reporting the key. Packages are not declared here: a quill vendors them under packages/, as the Typst Backend Guide describes.
Reading the schema programmatically¶
Quillmark emits a public schema contract derived from Quill.yaml. Accessors:
- Rust:
QuillConfig::schema()(JSON) /QuillConfig::schema_yaml()(YAML) - Python:
quill.schema(structured dict) - WASM:
quill.schema(JSON) - CLI:
quillmark schema <path>
ui: hints are preserved verbatim in the output. See SCHEMAS.md for the emitted shape.
Complete Example¶
quill:
name: project_report
version: "1.0.0"
backend: typst
description: Monthly project status report
author: Engineering Team
main:
fields:
project_name:
type: text
status:
type: enum
values: [on_track, at_risk, blocked]
risk_description:
type: text
default: ""
description: Describe the risk or blocker. Only needed when status is not on_track.
date:
type: date
team:
type: object
properties:
members:
type: array
items:
type: text
default: []
financials:
type: object
properties:
budget:
type: number
default: 0
card_kinds:
milestone:
description: A project milestone with target date and status.
fields:
name:
type: text
target_date:
type: date
completed:
type: boolean
default: false
Next Steps¶
- Creating Quills: hands-on tutorial
- Markdown Syntax: document authoring syntax
- CLI Reference: validating quills with the
validatecommand