Introduction
Lini draws figures from plain text. Diagrams, mindmaps, charts, sequences, ER schemas, engineering drawings, floor plans and circuit schematics are all layouts of one small language, and every one of them compiles to clean, themeable SVG. From mindmap to blueprint, in the same file format.
{
gap: 70; clearance: 20;
.step { direction: column; gap: 6; padding: 6; }
|icon| { width: 46; height: 46; }
#coffee |icon| { fill: --amber-soft; stroke: --amber-ink; }
#code |icon| { fill: --sky-soft; stroke: --sky-ink; }
#bug |icon| { fill: --rose-soft; stroke: --rose-ink; }
#ship |icon| { fill: --lime-soft; stroke: --lime-ink; }
|-| { stroke: --gray-deep; font-size: 12; }
}
|block#coffee| .step [ |icon| "coffee"; "Coffee" ]
|block#code| .step [ |icon| "code"; "Code" ]
|block#bug| .step [ |icon| "bug"; "Bug" ]
|block#ship| .step [ |icon| "rocket-launch"; "Ship" ]
coffee -> code -> bug
bug ~> coffee
code -> ship "tests pass"
That fence is the figure’s whole source: four steps, four wires, and a wavy one back to the kettle. You say what there is and what connects to what. Lini sizes the boxes, routes the wires around whatever is in the way, slides the label to where it fits, and paints from a palette that already knows about dark mode. Every one of those choices has a property that overrides it, resolved through a CSS-like cascade, so a figure starts pretty and stays yours.
The same six forms — a capsule for identity, a string for the label, braces for style, brackets for content, a class, a link — carry every layout:
| Family | The layout reads… | Tour |
|---|---|---|
| Diagrams | boxes and the wires between them | Diagrams, Links |
| Trees & mindmaps | nesting as hierarchy | Trees & mindmaps |
| Tables & entities | rows and columns; a titled field list | Tables & entities |
| Sequences | source order as time | Sequences |
| Charts | data as bars, lines, areas, bubbles and slices | Charts |
| Drawings | geometry it can measure — the dimensions read themselves | Drawings |
| Floor plans | walls, openings and fixtures at true size | Floor plans |
| Schematics | parts on a lattice, wires on pins | Schematics |
Install
cargo install lini
lini figure.lini -o figure.svg # compile to SVG
lini serve figure.lini # live-reloading preview
lini fmt figure.lini # canonical formatting (--check for CI)
Or skip the install: the playground runs the same compiler in your browser, and every fence in this book pastes straight into it.
Where to go
- The Tour starts with the language — six forms, one page — then walks each family in a few minutes apiece, and ends with style, colour & themes and the tooling.
- The Reference is the full language specification, one section per
chapter, split straight from the repository’s
SPEC.md. - The gallery shows every sample the language ships, source beside figure.
The language
Six forms, and that is the whole syntax. Every page after this one adds a layout — a new way to arrange children. None of them adds a rule.
One line draws
draft -> review -> publish
Nothing was declared there. A link to a name nobody declared creates a box for it, labelled with the name.
A chain marks every hop, so that line is exactly draft -> review and
review -> publish.
Declare a node when you have more to say about it
|oval#visitor| "Visitor"
|box#api| "API"
|cyl#db| "Postgres"
visitor -> api -> db
The bars carry the identity — a type, and an id after #. The quoted
string is the label.
Only the bars are required, and both halves inside them are optional:
|box#api|is a box namedapiwith no label — a blank card, not a card reading “api”.|#api|is that same box. A node with no type is a|box|.|oval|is an oval nothing can link to, because an id is the one thing you reference.
Style is a block on the node
|box#edge| "Edge"
|box#core| "Core" { fill: --rose-wash; stroke: --rose-deep; radius: 14; }
edge -> core
A { } after the label is that node’s style: key: value; declarations,
and the vocabulary is the same everywhere in the language.
Children go in brackets
{ gap: 76; }
|box#web| "Web" { fill: --rose-wash; stroke: --rose-deep; color: --rose-ink; }
|box#api| "API" { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
|group#store| "Store" { direction: column; gap: 26; padding: 22; } [
|box#cache| "Cache" { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
|cyl#db| "Postgres" { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
]
web -> api
api -> store.cache
api --> store.db
A [ ] holds content — children, in the order written.
A named container gives its children a path, which is how store.cache and
store.db reach them from outside. There is no search: cache alone at the
root would make a new box rather than find that one.
Four nodes here carry the same three declarations. That is the itch the next section scratches.
The stylesheet on top
Everything so far styled one node at a time. A { } block standing before
any drawn statement is the stylesheet: it draws nothing, and it says what
whole kinds of thing look like.
{
gap: 80;
|box| { radius: 4; }
|-| { stroke: --gray-deep; font-size: 12; }
.hot { fill: --rose-wash; stroke: --rose-deep; }
|svc::box| { fill: --purple-wash; stroke: --purple-deep; }
}
|svc#web| "Web"
|svc#api| "API" .hot
web -> api "HTTPS"
Four kinds of entry, each reaching a different way:
|box| { }is a rule — every node of that type.|-|is the link type, so that line styles every link on the page..hot { }is a class, worn after the label:|svc#api| "API" .hot.|svc::box| { }is a define — a new type carrying its own defaults over a base one.gap: 80is a bare declaration, and belongs to the root. The file is the body of an implicit root container, so its children are your top-level nodes.
An #id { } rule reaches exactly one node. When several of these touch the
same property, the more specific one wins; the style page walks
the ladder.
The six forms
Everything above is built from these, and there are no others:
| Form | Means |
|---|---|
|type#id| | Identity — a type and an optional id, always in bars. |
"…" | The label. Each type decides where it goes: centred in a box, the caption of a group, the title of a chart. |
{ … } | Style — declarations. On a node it is that node’s; before any statement it is the root’s stylesheet. |
[ … ] | Content — children, in the order written. |
.name | A class, defined once and worn after the label. |
a -> b | A link — two ids and an operator. The operator is the look. |
A drawn node is |type#id| "label" .class { style } [ children ], and a link
is the same tail on a different head: a -> b "label" .class { style }. A name
goes bare only when it is being referenced, and the one thing you reference is
an id — which is why types and classes always carry their sigil.
Where each family starts
A layout: on any container — the root, or any node — picks the family:
| Write | Get | Tour |
|---|---|---|
layout: flow (default) · grid | boxes in a row, a column or a grid | Diagrams |
layout: tree · |mindmap| | a hierarchy from nesting alone | Trees & mindmaps |
|table| · |entity| | ruled grids and ER cards | Tables & entities |
layout: sequence | participants, lifelines and messages on a time axis | Sequences |
layout: chart · pie | bars, lines, areas, dots, slices | Charts |
layout: drawing | a dimensioned engineering sheet | Drawings |
layout: floorplan | walls, openings and fixtures | Floor plans |
layout: schematic | parts, pins and routed wires | Schematics |
The nodes, links, cascade and colours are identical in every one. A layout only decides where its children go, and what its links mean.
Diagrams
Boxes, and the wires between them. This is the default family — nothing to declare — and the one every other page borrows its nodes and links from.
A row, or a column
{ direction: column; gap: 35; }
|oval#visitor| "Visitor"
|box#api| "API"
|hex#worker| "Worker"
|cyl#db| "Postgres"
visitor -> api -> worker -> db
Every container is a flow until told otherwise. direction: row reads left to
right, column top to bottom, and gap: is the space between children.
That one word is the whole of it. Change column to row and the same four
nodes read across the page instead of down it.
The shapes
{
layout: grid; columns: repeat(5); gap: 26; padding: 24;
|tile::column| { gap: 9; align: center; }
|cap::block| { font-size: 12; color: --muted; }
}
|tile| [ |block| "block" { padding: 12 16; fill: --rose-wash; stroke: --rose-deep; }; |cap| "block" ]
|tile| [ |box| { width: 62; height: 42; fill: --rose-wash; stroke: --rose-deep; }; |cap| "box" ]
|tile| [ |rect| { width: 62; height: 42; fill: --orange-wash; stroke: --orange-deep; }; |cap| "rect" ]
|tile| [ |oval| { width: 64; height: 46; fill: --amber-wash; stroke: --amber-deep; }; |cap| "oval" ]
|tile| [ |hex| { width: 64; height: 46; fill: --lime-wash; stroke: --lime-deep; }; |cap| "hex" ]
|tile| [ |slant| { width: 64; height: 42; fill: --green-wash; stroke: --green-deep; }; |cap| "slant" ]
|tile| [ |cyl| { width: 56; height: 46; fill: --teal-wash; stroke: --teal-deep; }; |cap| "cyl" ]
|tile| [ |diamond| { width: 62; height: 46; fill: --sky-wash; stroke: --sky-deep; }; |cap| "diamond" ]
|tile| [ |poly| { points: 0 -26, 26 -6, 15 24, -15 24, -26 -6; fill: --blue-wash; stroke: --blue-deep; }; |cap| "poly" ]
|tile| [ |line| { points: -30 0, 30 0; marker-end: arrow; stroke: --purple-deep; stroke-width: 3; }; |cap| "line" ]
|tile| [ |path| { path: "M -30 24 Q 0 -26 30 24"; stroke: --purple-deep; stroke-width: 4; fill: none; }; |cap| "path" ]
|tile| [ |icon| { symbol: house; width: 34; height: 34; stroke: --red-deep; fill: none; }; |cap| "icon" ]
|tile| [ |sketch| { draw: move(0, 0) right(56):a down(34) left(56) close(); fill: --rose-wash; stroke: --rose-deep; }; |cap| "sketch" ]
|tile| [ |box| "badge" { width: 62; height: 42; fill: --gray-wash; stroke: --gray-deep; font-size: 11; } [ |badge| "3" ]; |cap| "+ badge" ]
Those are the primitives. Every template — |group|, |table|, |badge|,
|mindmap|, and the rest — is a bundle of defaults over one of them, so
anything you can do to a |box| you can do to a |group|.
A group frames its children
{
gap: 60;
|group| { direction: column; gap: 16; }
.client { fill: --rose-wash; stroke: --rose-deep; color: --rose-ink; }
.backend { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
}
|group#browser| "Browser" [
|box#ui| "UI" .client
|box#cache| "Cache" .client
]
|group#server| "Server" [
|box#api| "API" .backend
|cyl#db| "Postgres" .backend
]
browser.ui -> server.api "fetch"
server.api -> server.db
server.api --> browser.cache "store"
A |group| draws a frame around a region and lifts its label to a caption
above the corner.
Links written inside a group’s own [ ] name that group’s children bare;
written at the root, as these are, they need the path.
A grid, with cells and spans
{ layout: grid; columns: 120, 120, 120; gap: 16; align: stretch; justify: stretch; }
|box#head| "Header" { span: 3; fill: --sky-wash; stroke: --sky-deep; }
|box#nav| "Nav" { fill: --amber-wash; stroke: --amber-deep; }
|box#main| "Main" { span: 2 2; fill: --teal-wash; stroke: --teal-deep; }
|box#side| "Side" { fill: --amber-wash; stroke: --amber-deep; }
|box#foot| "Footer" { cell: 1 4; span: 3; fill: --sky-wash; stroke: --sky-deep; }
layout: grid needs columns: — fixed sizes, auto, or repeat(N) — and
fills left to right, wrapping at the count.
span: cols rowscovers several tracks.cell: col rowplaces one child exactly; the rest flow around it.- Tracks are floors, not ceilings: a track grows to its widest child, so a grid never clips.
align and justify set to stretch are what fill each cell with its child —
without them the spans would be invisible.
Out of the flow: pin and translate
{ gap: 40; }
|box#card| "Card" { width: 160; height: 90; fill: --purple-wash; stroke: --purple-deep; } [
|badge| "3"
|block| "draft" { pin: bottom left; translate: 8 -6; color: --muted; font-size: 11; }
]
|box#plain| "Plain" { width: 100; height: 90; translate: 0 20; }
pin: lifts a child out of the flow and seats it on a point of its parent — a
center, an edge, or a corner — as an overlay that never grows the parent. A
|badge| is exactly that: a pinned block with the offset already set.
translate: x y nudges any node after it has been placed, without moving its
siblings. So pin: center plus a translate: is an exact coordinate with no
arithmetic.
Alignment and stretch
{
gap: 24;
|box| { fill: --teal-wash; stroke: --teal-deep; color: --teal-ink; }
}
|group#start| "align: start" { direction: column; align: start; width: 140; } [
|box| "short"
|box| "a longer one"
]
|group#stretch| "align: stretch" { direction: column; align: stretch; width: 140; } [
|box| "short"
|box| "a longer one"
]
|group#end| "justify: end" { direction: column; justify: end; height: 160; } [
|box| "low"
]
justify runs along a flow’s axis; align runs across it. Both default to
center.
stretch fills a child to the cross axis, and evenly spreads the main-axis
slack. Neither does anything until the container is bigger than its packed
children — an explicit width: or height:, or a grid’s fixed tracks, is what
makes the slack to spread.
Go deeper
The layout model and flow, grid, stack & tree have the full rules; the box model covers sizing, padding and wrapping.
Links
You place the nodes; Lini routes the wires. A link is two ids and an operator, and the operator is the whole look.
The operator is the look
{
direction: column; gap: 14;
|row| { gap: 150; }
|box| { width: 60; padding: 6; font-size: 13; }
|-| { font-size: 12; }
}
|row| [ |box#a1| "a"; |box#b1| "b"; a1 -> b1 "->" ]
|row| [ |box#a2| "a"; |box#b2| "b"; a2 <-> b2 "<->" ]
|row| [ |box#a3| "a"; |box#b3| "b"; a3 --> b3 "-->" ]
|row| [ |box#a4| "a"; |box#b4| "b"; a4 ---> b4 "--->" ]
|row| [ |box#a5| "a"; |box#b5| "b"; a5 ~> b5 "~>" ]
|row| [ |box#a6| "a"; |box#b6| "b"; a6 *-* b6 "*-*" ]
|row| [ |box#a7| "a"; |box#b7| "b"; a7 -<> b7 "-<>" ]
|row| [ |box#a8| "a"; |box#b8| "b"; a8 - b8 "-" ]
An operator is [start marker][line][end marker], glued together with no
spaces. That is the whole grammar:
| Line | Marker | ||
|---|---|---|---|
- | solid | > | an arrow |
-- | dashed | * | a dot |
--- | dotted | <> | a diamond |
~ | wavy |
So --> is a dashed arrow, and *-* a solid line with a dot at each end. A
bare line op leaves both ends plain.
The ER cardinality markers ride the same scheme — Tables & entities shows them.
Labels ride the route
{
gap: 120;
|box| { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
}
|box#client| "Client"
|box#server| "Server"
|box#store| "Store"
client -> server "request"
server -> store { along: 0.25, 0.75; } [ "read" "write" ]
store --> client [ "rows" { color: --teal-ink; font-weight: bold; } ]
One label follows the head, and slides along the route to keep clear of nodes.
Several labels, or a styled one, ride the link’s own [ ] — and along: fixes
each as a fraction of the route, so 0.25, 0.75 puts them at the quarter and
three-quarter marks.
A label cuts its wire where it sits, so it reads over any background.
Chains and fans
{
gap: 60;
|box| { fill: --sky-wash; stroke: --sky-deep; color: --sky-ink; }
|cyl| { fill: --teal-wash; stroke: --teal-deep; color: --teal-ink; }
}
|box#lb| "LB" { fill: --rose-wash; stroke: --rose-deep; color: --rose-ink; }
|column#apps| { gap: 20; } [
|box#a| "app-1"
|box#b| "app-2"
|box#c| "app-3"
]
|cyl#db| "DB"
lb -> apps.a & apps.b & apps.c
apps.a & apps.b & apps.c -> db
a -> b -> c is a chain: two links, every hop marked.
& groups endpoints, and which side you group decides the shape:
lb -> a & b & cfans out.a & b & c -> dbfans in.- Both sides grouped is a cartesian product — every left to every right.
A fan shares its trunk wherever the geometry allows, which is what makes the two bundles above read as buses rather than as six separate wires.
Steering: sides, clearance and dotted paths
{ gap: 70; clearance: 20; }
|box#a| "a"
|box#b| "b"
|box#c| "c"
a -> b -> c
a:top -> c:top "skip"
c:bottom -> a:bottom "retry"
Without a side the router picks the edges by geometry. a:top forces one.
clearance is the gap a wire keeps from every node and every other wire. Set
it on a container and it cascades to the links written inside.
It shapes the drawing as well as spacing it: corners round to fit the clearance, and a parallel run steps aside so two wires never share a track.
Three routing strategies
{
gap: 40;
|group| { layout: grid; columns: repeat(2); gap: 44 60; padding: 24; }
|box| { fill: --amber-wash; stroke: --amber-deep; color: --amber-ink; }
}
|group#o| "orthogonal" [
|box#o1| "a"; |box#o2| "b"
|box#o3| "c"; |box#o4| "d"
o1 -> o4
o2 -> o3
]
|group#n| "natural" { routing: natural; } [
|box#n1| "a"; |box#n2| "b"
|box#n3| "c"; |box#n4| "d"
n1 -> n4
n2 -> n3
]
|group#s| "straight" { routing: straight; } [
|box#s1| "a"; |box#s2| "b"
|box#s3| "c"; |box#s4| "d"
s1 -> s4
s2 -> s3
]
routing: is scene config, like clearance:
orthogonal(the default) — horizontal and vertical legs through the free space between nodes.natural— smooth curves that bend around what they would hit, and are free to cross.straight— one segment between the bodies, avoiding nothing.
A group can route its internals one way while the root routes another, which is what the figure above is doing.
Styling a link
{
gap: 70;
|-| { stroke: --gray-deep; stroke-width: 1.5; font-size: 12; }
.hot { stroke: --red-deep; stroke-width: 2.5; color: --red-ink; }
}
|box#a| "a"
|box#b| "b"
|box#c| "c"
a -> b "cold"
b -> c "hot" .hot
A link is styled exactly like a node. stroke, stroke-width and
stroke-style dress the wire; color and the font-* family dress its labels.
The cascade is the same one too: |-| { } is the rule for every link, a class
applies to whichever nodes or links wear it, and a link’s own { } wins.
There is no separate link-* vocabulary to learn.
Go deeper
Links in the reference covers endpoints, scope and capsule endpoints; the full routing contract — priority, crossings, self-loops — is ROUTING.md.
Trees & mindmaps
layout: tree reads |topic| nesting as the hierarchy and draws the branch
wires for you. A mindmap is that same tree wearing a preset.
Nesting is the whole structure
{
layout: tree;
|topic| { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
}
|topic#ceo| "Chief Executive" [
|topic#eng| "Engineering" [
|topic| "Platform"
|topic| "Product"
]
|topic#ops| "Operations" [
|topic| "Support"
|topic| "Finance"
]
]
There are no links in that source. Every wire was generated — one fan per
parent — and lini desugar will show you them.
A tree scope holds exactly one root topic. Inside a topic’s [ ]:
- a
|topic|is a branch; - anything else is that topic’s own content, which is how an icon or a caption rides along. The last figure on this page uses it.
Which way it grows
{
layout: tree; direction: row; gap: 48 20;
|topic| { fill: --rose-wash; stroke: --rose-deep; color: --rose-ink; }
|-| { stroke: --rose-deep; }
}
|topic#req| "Request" [
|topic#triage| "Triage" [
|topic| "Approve"
|topic| "Reject"
|topic| "Escalate"
]
|topic#auto| "Auto-close" [
|topic| "Duplicate"
]
]
Three directions, and each one is a different document:
column(the default) grows down — an org chart.rowgrows right — an outline, like this one.bilateralgrows both ways from a centred root — a mindmap.
gap: reads two numbers here: the distance between generations first, then the
separation between siblings.
The mindmap preset
|mindmap#launch| "Launch" [
|topic#product| "Product" [
|topic| "MVP scope"
|topic| "Docs and a playground"
]
|topic#market| "Marketing" [
|topic| "Site"
|topic| "Launch post"
]
|topic#sales| "Sales" { side: left; } [
|topic| "Leads"
|topic| "Pricing page"
]
|topic#team| "Team" [
|topic| "Hiring plan"
]
]
|mindmap| is the visible root topic with a whole scene preset folded in —
none of the colour above is written:
direction: bilateral, androuting: naturalfor the smooth arms.- A palette walk: each first-level branch takes the next hue and tints its whole subtree — wash fill, deep stroke and wire, ink text.
- A depth ramp that sizes the type down by level.
- A
max-width, so a long topic wraps into a card instead of running away.
Bilateral fills the right side first. side: left on a first-level topic
overrides the split, which is what moved Sales across.
Flaring one arm
{
.lini-level-1 { font-weight: bold; }
.lini-level-2 { font-size: 12; color: --muted; }
#build |-| { stroke-style: dashed; }
.now {
fill: --rose-wash; stroke: --rose-deep; color: --rose-ink;
stroke-width: 2.5; font-size: 15; padding: 10 16;
}
|when::block| { font-size: 11; color: --muted; }
}
|mindmap#plan| "Plan" [
|topic#build| "Build" [
|icon| { symbol: gear; width: 18; height: 18; stroke: --purple-deep; fill: none; }
|topic#compiler| "Compiler"
|topic| "Formatter"
]
|topic#ship| "Ship" .now [
|icon| { symbol: rocket; width: 20; height: 20; stroke: --rose-deep; fill: none; }
|topic| "Binary"
|topic| "Crate"
|when| "this quarter"
]
|topic#docs| "Docs" { side: left; } [
|topic| "The tour"
|topic| "Reference"
]
|topic#test| "Test" { side: left; } [
|topic#conformance| "Conformance"
|topic| "Snapshots"
]
]
plan.docs ~> plan.test
Everything on that figure is reachable from the stylesheet:
- A tier at a time. Every topic wears a generated
.lini-level-Nclass, root at 0 — so one rule restyles a whole generation. - One arm at a time. A branch link cascades as if it were written inside
its parent, so
#build |-| { }dashes exactly the wires under Build and nothing else. - One topic. A class does what it does anywhere else:
.nowflares Ship out of the palette walk. - Content rides along. The two
|icon|children and the|when|block are ordinary content in a topic’s[ ], not branches, so they decorate without joining the hierarchy.
Authored cross-links between topics stay legal and never alter the tree —
plan.docs ~> plan.test is the wavy one, drawn over the generated ones.
Go deeper
Flow, grid, stack & tree has the
placement rules; the |topic| and |mindmap| bundles are in
templates.
Tables & entities
A |table| is a grid with ruled gutters; an |entity| is that table shaped
like a database card. Neither adds grammar — cells are bare strings, and
relationships are ordinary links.
Cells are strings, in row order
|table#plans| "Plans" { columns: 120, 80, 90; } [
"Plan" "Seats" "Price"
"Free" "1" "$0"
"Team" "10" "$120"
"Scale" "50" "$480"
]
columns: fixes the track widths and the count, so the rows need no
separators — the count is what breaks them. The first row becomes the header
band, and the table’s label is its caption.
lini fmt knows that count too, and pads the cells into aligned columns, so
the source keeps the shape of the thing it draws.
Alignment, a footer, and one cell that speaks up
{
|table| { fill: --purple-wash; stroke: --purple-deep; }
.bad { color: --red-ink; font-weight: bold; }
}
|table#regions| "Edge regions" { columns: 110, 90, 80, 90; align: start, end, end, center; } [
"Region" "Requests" "p95" "Status"
"us-east" "18.2M" "84 ms" "Healthy"
"eu-west" "11.4M" "96 ms" "Healthy"
"ap-south" "6.8M" "142 ms" "Degraded" .bad
|footer| "Total"; |footer| "36.4M"; |footer| "—"; |footer| "3 regions"
]
align: takes one value per column — start, end or center — and moves the
header with the body, so a numeric column lines up on its digits.
A cell is a node like any other, which is the whole trick here:
- Give one a class or a
{ }and it styles itself, asDegradeddoes. - Wrap a last-row cell in
|footer|and it joins the muted footer band. - Reach them all with
|cell| { }, or just one table’s with|table#regions| |cell| { }.
Entities and the crow’s foot
{
gap: 120;
|entity| { fill: --rose-wash; stroke: --rose-deep; }
}
|entity#users| "Users" { columns: auto, auto, auto; } [
"PK" "id" "int"
"" "email" "varchar"
"" "name" "varchar"
]
|entity#orders| "Orders" { columns: auto, auto, auto; } [
"PK" "id" "int"
"FK" "user_id" "int"
"" "total" "decimal"
]
users +-< orders "places"
An |entity| is a table whose label is a title spanning every column, over
left-aligned rows. Two auto columns is the default; a third gives you the
PK / FK gutter.
The relationship is a plain link wearing a crow’s-foot operator:
| Op | Reads | Op | Reads |
|---|---|---|---|
-+ | one | -o+ | zero or one |
-< | many | -+< | one or many |
-++ | exactly one | -o< | zero or many |
Each marker composes [min][max]: the ring o or the bar + hugs the line,
and the crow < sits against the entity. Either end takes one, so
users +-< orders reads one-to-many.
They are sugar over marker: crow, one, zero-or-many and the rest — so
marker-end: still overrides a single end when you need it to.
A schema, wired field to field
{
layout: grid; columns: repeat(3); gap: 60 90; clearance: 16;
|entity| { fill: --sky-wash; stroke: --sky-deep; }
#post { fill: --purple-wash; stroke: --purple-deep; }
#post_tag { fill: --amber-wash; stroke: --amber-deep; }
}
|entity#author| "Author" { cell: 1 1; } [ "id" "int" "name" "varchar" ]
|entity#post| "Post" { cell: 2 1; } [
|cell#pid| "id"; "int"
"author_id" "int"
"title" "varchar"
]
|entity#post_tag| "PostTag" { cell: 3 1; } [
|cell#pref| "post_id"; "int"
"tag_id" "int"
]
|entity#tag| "Tag" { cell: 3 2; } [ "id" "int" "name" "varchar" ]
author -< post "writes"
post.pid -< post_tag.pref
tag -< post_tag
Entities lay out like any other node — a grid here, but a flow, or pin and
translate, work the same.
A relationship lands on the card’s edge by default. To wire a specific
field, give that cell an id and link the path — |cell#pid| "id" makes the
cell addressable, so post.pid -< post_tag.pref leaves and arrives at those
two rows rather than at the two cards.
The join table needs no special treatment either: it is an entity like the others, and the two links into it are what make the many-to-many.
Go deeper
The |table|, |cell|, |header|, |footer| and |entity| bundles are in
templates; the operators in
links.
Text, icons & images
Text is content, not a shape — a bare string is a text node wherever it stands. Icons are Phosphor glyphs drawn as paths. Images embed.
A string is a node
{ direction: column; gap: 14; align: start; }
"Plain text stands on its own."
"Styled in place." { color: --rose-ink; font-weight: bold; }
"Two lines,\nwith a break." { font-style: italic; }
|box#card| { fill: --purple-wash; stroke: --purple-deep; } [
"A heading" { font-weight: semibold; font-size: 17; color: --purple-ink; }
"and a line of body text under it." { color: --purple-ink; }
]
Where the string stands is what it becomes:
- On its own — a free text node.
- Inside a shape’s
[ ]— that shape’s text. Several strings are several nodes, stacked, because a shape’s content flows as a column.
Either way it takes a style block of text properties — color, the font-*
family, letter-spacing, text-decoration, translate, rotate — and wears
classes like anything else.
Anything that needs a border, padding or an id wraps in a |block|: the
frameless box.
Wrapping
{
gap: 20; align: start;
|box| { max-width: 170; fill: --teal-wash; stroke: --teal-deep; color: --teal-ink; }
}
|box#a| "Ship the release notes to every customer who asked"
|box#b| "Rotate the signing keys before the audit window opens" { align: start; }
|box#c| "Archive dashboards nobody opened this quarter" { align: end; }
max-width caps a box’s automatic width, and the text wraps to honour it,
breaking at whitespace first. The wrapped size is the measured size, so those
three cards sit at their true heights rather than at a guess.
There is no text-align. Lines align by the box’s own packing knob — align
in a column box, justify in a row — which is why b and c above differ
without either one naming text.
Icons
{
gap: 26;
.tag { fill: --sky-wash; stroke: --sky-ink; color: --sky-ink; }
}
|icon| "bell"
|icon| "heart" { fill: --rose-wash; stroke: --rose-ink; }
|icon| "cloud" { fill: none; stroke: --sky-deep; }
|icon| "chat-circle" .tag [ "3" ]
|sign#docs| "folder-simple" .tag [ "Docs" ]
|icon| draws a Phosphor icon named by its label
— or by symbol: — as inline paths. No icon font, and only the glyphs a figure
actually uses are embedded.
It paints like a box:
fillis the soft body,strokethe line.fill: nonemakes it a single-tone line icon, ascloudis above.- It is a square that grows with its
[ ]text.
|sign| is the larger preset with room for a word, and it takes links like any
other node.
Icons make cards
{
layout: grid; columns: repeat(3); gap: 20;
|card::box| { padding: 16 20; }
|stat::block| { font-size: 22; font-weight: bold; }
|cap::block| { font-size: 12; color: --muted; letter-spacing: 1; }
}
|card| { fill: --rose-wash; stroke: --rose-deep; } [
|icon| "bell" { fill: --rose-wash; stroke: --rose-ink; }
|stat| "128" { color: --rose-ink; }
|cap| "ALERTS"
]
|card| { fill: --purple-wash; stroke: --purple-deep; } [
|badge| "!"
|icon| "lock" { fill: --purple-wash; stroke: --purple-ink; }
|stat| "24" { color: --purple-ink; }
|cap| "KEYS"
]
|card| { fill: --lime-wash; stroke: --lime-deep; } [
|icon| "users" { fill: --lime-wash; stroke: --lime-ink; }
|stat| "1.4k" { color: --lime-ink; }
|cap| "TEAM"
]
Nothing here says direction. A shape’s children stack as a column, so an
icon over a number over a caption is just three children in order.
The rest is the language you already have — a define per role, a class-free
{ } where one card differs, and a |badge| pinned to the middle card, which
is the same overlay the diagrams page pinned to a card.
That icon is one node type everywhere: the same |icon| is a sequence
participant’s glyph, or a tree topic’s decoration.
Images
|image#logo| { src: "assets/logo.svg"; width: 120; height: 60; }
|image#photo| { src: "https://example.com/site.png"; width: 320; height: 180; fit: cover; }
|image| needs src, width and height. What happens next depends on where
the source lives:
- A local path is read once and embedded, so the output stays
self-contained — an SVG asset as a nested, id-isolated
<svg>, a raster as a base64 data URI. - A URL passes through untouched.
fit: maps the picture into its box: auto letterboxes, and contain,
cover and stretch do what CSS says.
Go deeper
Statements & the label covers text content, paint, stroke & text the text family, and nodes icons and images.
Sequences
layout: sequence reads a diagram on a time axis: participants across the top,
messages down the page in the order you wrote them.
Two lines are a diagram
{ layout: sequence; }
user -> server "login"
server --> user "token"
The links are the messages, and source order is time: the first line written is the first row drawn.
Nothing was declared here either. An endpoint nobody declared creates a
participant, appended in first-use order, so a quick exchange needs no header
block at all. -> is a call, --> a return.
Participants you declare
{ layout: sequence; }
|box#user| "Customer" [ |icon| "user" { fill: none; stroke: --rose-deep; } ]
|box#shop| "Storefront" { fill: --sky-wash; stroke: --sky-deep; }
|cyl#db| "Orders" { fill: --orange-wash; stroke: --orange-deep; }
user -> shop "place order"
shop -> db "reserve stock"
db --> shop "reserved"
shop --> user "order #1024"
Declaring the participants buys three things at once: their left-to-right order, their type, and their paint.
That paint carries down the page. A participant lends its own fill and
stroke to its apparatus — the lifeline and every activation bar — so
colouring a header colours its whole timeline.
An actor is just a box wrapping an |icon|: the name stays the header, and the
glyph becomes the picture.
What the operators mean
{
layout: sequence;
|box| { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
}
|box#shop| "Storefront"
|box#pay| "Payments"
shop -> pay "charge $42"
pay -> pay "verify card"
pay --> shop "receipt"
shop ~> pay "queue settlement"
Three operators, three different things on the page:
->a call. It opens an activation bar on its target; the next return from that target closes it, and nested calls stack outward.a -> aa self-message. Internal work, hooked back onto the same lifeline. It opens no bar.~>async. Drawn wavy, and expecting no reply.
Repeating a span
{
layout: sequence;
|box| { fill: --rose-wash; stroke: --rose-deep; color: --rose-ink; }
|cyl| { fill: --teal-wash; stroke: --teal-deep; color: --teal-ink; }
|loop| { fill: --amber-wash; stroke: --amber-deep; }
}
|box#shop| "Storefront"
|box#pay| "Payments"
|cyl#db| "Orders"
shop -> pay "charge $42"
pay --> shop "receipt"
|loop| "each item" [
shop -> db "reserve stock"
db --> shop "in stock"
]
|note| "one round trip per line item" { place: over shop db; }
A |loop| frame wraps the rows that repeat, draws them once, and uses its
label as the guard.
Its [ ] groups messages for layout but opens no scope — the wires inside
still name the sequence’s own participants, which is why shop and db need
no path.
A |note| is a callout bound to lifelines by place::
over shop— on one lifeline.over shop db— spanning from one to another.left shop/right shop— beside one.
Branching
{
layout: sequence;
|box| { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
#pay { fill: --rose-wash; stroke: --rose-deep; color: --rose-ink; }
|alt| { fill: --purple-wash; stroke: --purple-deep; }
}
|box#user| "Customer"
|box#shop| "Storefront"
|box#pay| "Payments"
user -> shop "submit card"
shop -> pay "authorize"
|alt| "accepted" [
pay --> shop "approved"
shop --> user "receipt"
|else| "declined"
pay --> shop "refused"
shop --> user "ask for another card"
]
An |alt| holds two or more compartments split by |else|. Each label is that
branch’s guard, and the |alt|’s own label guards the first compartment.
|opt| is the same frame with a single branch, for a step that only sometimes
happens.
Go deeper
The full sequence reference covers activation control, frame nesting, note placement and the spacing knobs.
Charts
layout: chart reads all of its children, fixes one shared scale from their
data, then draws — bars, lines, areas, dots and bubbles on one plane, and the
part-to-whole layout: pie.
Categories and one series
|chart| "Deploys per weekday" { categories: "Mon", "Tue", "Wed", "Thu", "Fri"; } [
|bars| { data: 4, 9, 6, 12, 7; }
]
categories: names the x axis and data: gives one value per category — the
two counts must match, and a mismatch is an error rather than a short bar.
Nothing here says how anything should look. The chart’s label became the title, the value axis fitted itself to the numbers, and the colour came off the palette.
More than one series
|chart| "Revenue by region ($k)" {
categories: "Q1", "Q2", "Q3", "Q4"; bars: stacked;
} [
|bars| "NA" { data: 40, 45, 50, 60; }
|bars| "EU" { data: 30, 35, 33, 40; }
|bars| "APAC" { data: 20, 25, 30, 38; }
]
Every series is a child node, and its label is its legend entry — the legend appears on its own the moment there are two.
bars: on the chart decides how the bar series combine:
grouped(the default) — side by side.stacked— piled into a total, as here.overlay— drawn over one another.
Series walk the palette in order, so three series are three distinct hues with no colour declared.
Lines, axes and a reference mark
|chart| "Latency (ms)" { categories: "p50", "p90", "p99"; } [
|axis#ms| { side: left; }
|mark| "budget" { at: 250; axis: ms; stroke: --red-deep; stroke-style: dashed; }
|line| "before" { data: 40, 180, 420; curve: smooth; marker: circle; }
|line| "after" { data: 30, 120, 260; curve: smooth; marker: circle; }
]
|line| draws one shape through its data. curve: smooth interpolates without
overshooting any point, and marker: puts a mark on every datum.
Write an |axis| only when you have something to say about it — here, only to
carry an id so the |mark| can anchor to it.
A mark is placed in data space, not pixels. It tracks the scale, and
survives a direction: row flip unchanged.
A formula, and a band across it
|chart| "Edge requests per second" { samples: 96; } [
|axis#rps| { side: left; range: 0 900; }
|axis#hour| "Hour of day (UTC)" { side: bottom; range: 0 24; step: 4; }
|area| { fn: (70 + 620 * exp(-((x - 10.5)^2) / 7) + 470 * exp(-((x - 19)^2) / 11)); }
|band| "over ceiling" { range: 9.5 11.5; axis: hour; fill: --amber; }
|mark| "autoscale ceiling" { at: 600; axis: rps; stroke: --red-deep; stroke-style: dashed; }
]
A series may compute its values instead of listing them. fn: is an
expression in x, sampled samples: times across the domain, with the whole
expression engine available — locals, the ternary, and any
function the stylesheet binds. Two Gaussians make the morning and evening
peaks here.
range: on an axis is the visible window, and it is doing two different jobs
above:
- On the x axis it is the formula’s domain — what
fn:sweeps. - On the value axis it leaves headroom above the mark.
Points, labels and hover
|chart| "Score vs. thinking budget" { width: 460; height: 280; } [
|axis| "tokens per task (k)" { side: bottom; }
|axis| "eval score (%)" { side: left; range: 40 90; }
|line| "GLM-5.2" { data: 9 52, 16 63, 27 72, 48 79, 88 83; labels: "off", "low", "mid", "high", "max"; curve: smooth; marker: circle; tooltip: always; }
|dots| "GLM-5.1" { data: 5 47, 12 54, 20 58, 29 62, 40 65, 54 68, 68 69, 86 71; }
]
x y pairs make a numeric x axis — a scatter, rather than the categories of
the first two figures.
labels: is the per-datum text, one entry per point. Where a label fits it
sits on the plot; where it does not it falls to a hover card, and
tooltip: always forces every one inline for export. Every marked point also
carries a native <title>, so the figure is readable by hover in any viewer.
Left to fit itself, the value axis would start at zero and crush these scores
into the top strip. range: 40 90 is the window that actually matters.
Dates and calendar ticks
|chart| "p95 latency, eu-west (ms)" { width: 560; } [
|axis#t| { side: bottom; step: 2 week; }
|band| "incident" { range: "2026-01-24" "2026-02-14"; axis: t; fill: --amber; }
|mark| "rollback" { at: "2026-02-09"; axis: t; stroke: --red-deep; stroke-style: dashed; }
|line| { data: "2026-01-05" 240, "2026-01-12" 232, "2026-01-19" 251, "2026-01-26" 384, "2026-02-02" 412, "2026-02-09" 301, "2026-02-16" 262, "2026-02-23" 244, "2026-03-02" 231, "2026-03-09" 225, "2026-03-16" 219; curve: smooth; marker: dot; }
]
A point’s x may be a quoted ISO date, and the dates are what make the axis a
time axis — there is no scale: to write.
Ticks are then calendar-aware: the span picks the unit, the ticks land on real
boundaries, and the label follows that unit. step: overrides it with a
calendar interval — 2 week here, or month — and a plain number is an error.
Everything else measured on that axis is written in the same literals. The
|band| shading the incident and the |mark| on the rollback both take dates,
because a dated domain is a domain like any other — the other kind is an error
either way round. All the arithmetic is UTC, so the figure renders the same in
every timezone.
Bubbles, and a third number
|chart| "Cost by service ($k / month)" { width: 460; height: 300; } [
|axis| "requests/s" { side: bottom; }
|axis| "p99 latency (ms)" { side: left; }
|bubble| "billing" { at: 300 60; value: 4; }
|bubble| "auth" { at: 1200 45; value: 7; }
|bubble| "cart" { at: 900 105; value: 9; }
|bubble| "media" { at: 2100 155; value: 40; }
|bubble| "search" { at: 3400 215; value: 26; }
|bubble| "feed" { at: 4200 95; value: 17; }
]
A |bubble| is one mark per node rather than a series of data: at: places it
on the plane, and value: sizes it.
The scale is by area, so four times the cost is twice the radius. That is what lets a bubble carry a third number without a third axis.
The label rides inside the bubble where it fits and beside it where it does not — and each bubble, being its own node, takes the next hue in the palette walk.
Radar
|chart| "Profiles" { direction: radial; categories: "Speed", "Range", "Armour", "Cost", "Stealth"; } [
|axis| { range: 0 5; }
|line| "Scout" { data: 5, 4, 2, 3, 5; }
|area| "Cruiser" { data: 3, 3, 5, 4, 2; fill: --purple; }
]
direction: radial bends the category axis into a ring — one spoke per
category — and turns the value axis into the radius. A |line| then closes
into a polygon, and an |area| fills one.
The data is untouched by that flip, which is the rule for every direction: the plane is projected differently, never re-authored.
Part to whole
|pie| "Spend by channel" { hole: 0.5; } [
|slice| "Ads" { value: 40; }
|slice| "SEO" { value: 25; }
|slice| "Email" { value: 20; }
|slice| "Direct" { value: 15; }
]
layout: pie turns each |slice|’s value: into an angle; the values need no
normalising, since the whole is their sum. hole: is the inner-radius fraction,
so 0.5 is a donut and the default 0 a full pie.
Go deeper
The full chart reference covers log and reversed
scales, per-datum paint, segmented formulas, format: and the tooltip rules.
Drawings
layout: drawing reads a figure as a dimensioned sheet: every child sits on
one shared datum, and the links become annotations whose values are measured
off the geometry rather than typed.
A plate and two dimensions
{ layout: drawing; density: 3; }
|rect#plate| { width: 120; height: 70; }
plate:left (-) plate:right { side: bottom; tol: 0.2; }
plate:top (-) plate:bottom { side: right; }
A drawing replaces flow with a datum: every child’s origin lands on it, and links stop being wires.
(-) is the linear measure. It spans two anchors and renders the distance it
finds — so 120 and 70 came from the rectangle, not from the annotation,
and nothing here types a number twice.
tol: 0.2appends±0.2to whatever was measured.side:names the edge a dimension stands off. It always sits outside the geometry.density:is pixels per millimetre, for the screen alone. No measured value reads it.
A profile drawn with the pen
{ layout: drawing; density: 3; }
|sketch#gusset| {
draw: move(-40, 40) right(80):base fillet(6):heel up(80):wall
line(-80, 80):flank close();
}
gusset:left (-) gusset:right { side: bottom; }
gusset:heel (o)
gusset:flank (<) gusset:base
|sketch| is a pen that folds to a path. draw: is a list of calls read left
to right — move, right, up, line, arc, curve, fillet, chamfer,
close.
A :segment glued to a call names what that call drew, which is how a
dimension lands on it later: fillet(6):heel makes the fillet answer to
heel.
Each glyph pictures what it measures:
(o)on a named arc reads its radius —R6.(<)between two line-like anchors draws the angle arc at their intersection —45°.
Holes, patterns and callouts
{ layout: drawing; density: 3; }
|rect#plate| { width: 140; height: 60; } [
|hole#bolt| { width: 8; translate: -45 0; pattern: grid(3, 1, 45, 0); }
]
plate:left (-) plate.bolt (-) plate:right { side: bottom; }
plate.bolt.1 (-) plate.bolt.2 { side: top; }
plate.bolt (o) "H7"
plate:bottom >- "A"
plate:top-left <- "BREAK EDGES 0.5"
A part’s features ride in its [ ], rigid with it. |hole| is round —
width: is its diameter — and it punches the part and draws its own centre
marks.
pattern: grid(3, 1, 45, 0) replicates it three across. The seed keeps the
node’s own position, and plate.bolt.2 addresses a copy by index.
A callout is a one-ended link written tip-first:
<-lands an arrow on the outline and runs the line out to the text.>-plants a datum triangle and frames its letter.
A measured callout composes in a fixed order — count, glyph, number, then the
label’s words — so plate.bolt (o) "H7" reads 3× ⌀8 H7 with only the H7
typed.
A turned part that names its own thread
|drawing#view| "MAIN SHAFT" { scale: 0.75; } [
|sketch#shaft| {
draw: move(-60, 0) up(9) chamfer(1.5) right(34):m18 fillet(3)
up(6) right(46):brg down(15);
revolve: x-axis;
thread: m18 1.5;
}
shaft:left (-) shaft:right { side: bottom; }
shaft:brg (o) { side: right; tol: h6; }
shaft:m18 <- { side: top; }
]
revolve: x-axis folds the half profile into the whole part, and generates the
chrome a lathe drawing carries — the axis centreline, and a shoulder line at
each sharp diameter change. There is none at the fillet(3), because it joins
tangentially.
On a revolved profile the glyphs change meaning to suit:
(o)on a segment reads the span across the axis, as a diameter —⌀30 h6.thread: m18 1.5dresses that run as an ISO thread, and the bare leader composes its spec from the geometry:M18×1.5.
scale: 0.75 is the view’s drafting ratio. The dimensions still read true, and
the view’s label lowers to a title below it.
A sheet: two views and a title block
|page| { sheet: a5 landscape; gap: 50; align: origin; } [
|drawing#side| "LOCATING PIN" { scale: 1.5; } [
|sketch#pin| {
draw: move(-30, 0) up(6) right(20):shank chamfer(1) up(3) right(40):head down(9);
revolve: x-axis;
}
pin:left (-) pin:right { side: bottom; }
pin:head (o) { side: right; }
]
|drawing#end| { scale: 1.5; } [
|oval#od| { width: 18; height: 18; }
|oval| { width: 12; height: 12; }
od (o)
]
|title-block| {
title: "Locating pin"; drawing-number: "LN-0031"; revision: "A"; sheet-number: "1/1";
}
]
There is no |view| type. A multi-view sheet is just drawings in a flow,
and align: origin puts their datums on one line so the views share an axis —
which is the whole of projection here.
A |page| is the trimmed ISO sheet. sheet: a5 landscape generates its frame,
zone references and centring marks, and a |title-block| seats itself in the
frame’s corner from the fields you name.
The page’s width and height are real millimetres, so a print comes out true
to scale.
Go deeper
The full drawing reference covers mates and seating, sections and details, hatching, projection lines and the GD&T frames.
Floor plans
layout: floorplan is the drawing engine under an architect’s vocabulary: the
same datum, anchors and dimensions, with walls, openings and fixtures in place
of parts and features.
Walls and the openings in them
{ layout: floorplan; unit: m; scale: 0.02; }
|wall#outer| {
draw: move(0, 0) right(7.2):north down(4.8):east left(7.2):south close():west;
} [
|window| { on: north; at: 2.7; width: 1.6; }
|door#entry| { on: south; at: 1.05; width: 0.95; swing: right; }
|door| { on: west; at: 1.4; width: 2.4; symbol: sliding; }
]
|partition#bathwall| {
draw: move(4.9, 0) down(2.3):face right(2.3):side;
} [
|door| { on: side; at: 0.15; width: 0.8; }
]
A |wall|’s draw: traces the centreline. thickness: — 200 mm by
default, 100 for a |partition| — grows that line into the mitred outline that
takes the paint.
unit: m makes one drawing unit a metre, and scale: 0.02 is the 1:50 ratio.
An opening rides in its wall’s [ ], stationed on: a named segment at: a
distance from its start. The gap clips the wall at the jambs, and each type
draws its own chrome:
|door|— a leaf and a quarter swing arc, or two panels forsymbol: sliding.|window|— a sill pair.
The entry states no hinge: because every built-in size and default is real: a
door is 900 mm and hangs on its segment’s near jamb.
Mind the draw direction, twice. Both of these read from the pen, not from the picture:
at:counts from the segment’s start. The entry’s 1.05 is measured from the east end, becausesouthwas drawn leftward.swing:names the left of the pen’s travel — which on that same leftward run is the outside.rightis what opens the door into the flat.
The bathroom’s own wall ends at the shell rather than crossing it, which is what keeps a partition out of the middle of an opening.
Fixtures and room text
{
layout: floorplan; unit: m; scale: 0.02;
// a furniture plan tints what it is about, and leaves the shell alone
|bed| { fill: --purple-wash; stroke: --purple-deep; }
|sofa| { fill: --purple-wash; stroke: --purple-deep; }
|dining| { fill: --rose-wash; stroke: --rose-deep; }
|bath| { fill: --sky-wash; stroke: --sky-deep; }
|appliance| { fill: --sky-wash; stroke: --sky-deep; }
}
|wall#outer| {
draw: move(0, 0) right(7.2):north down(4.8):east left(7.2):south close():west;
} [
|window| { on: north; at: 2.7; width: 1.6; }
|door#entry| { on: south; at: 1.05; width: 0.95; swing: right; }
]
|partition#bathwall| {
draw: move(4.9, 0) down(2.3):face right(2.3):side;
} [
|door| { on: side; at: 0.15; width: 0.8; }
]
|bed| { rotate: 90; translate: 1.15 1.05; }
|sofa| { symbol: two; rotate: 90; translate: 0.6 3.4; }
|dining| { symbol: round; translate: 3.3 3.3; }
|rect#counter| { width: 0.6; height: 1.4; translate: 6.8 3.2;
fill: --bg; stroke: --stroke-dark; stroke-width: 1; }
|appliance| "F" { symbol: fridge; translate: 6.8 2.8; }
|bath| { symbol: shower; translate: 6.6 0.6; }
|bath| { symbol: toilet; rotate: 90; translate: 5.35 0.5; }
|bath| { symbol: sink; rotate: 90; translate: 6.85 1.6; }
"STUDIO 27 m²" { translate: 2.2 2.2; }
Six symbol-bodied types carry the furniture — |bed|, |sofa|, |dining|,
|bath|, |appliance|, |stairs| — and symbol: picks the variant, so one
type draws the tub, the toilet and the basin alike.
Their bodies are true millimetres, which is why not one of them states a
size. They place with translate: from the datum like any drawing child, and
they mask what they sit on.
rotate: turns a piece to its wall, and the reference is the unturned pose: a
toilet backs west at 0°, so 90° stands it against a north wall.
Two things that are not special types:
- A room name is plain sheet text. There is no room type, because a room is what the walls already enclose.
- A counter is a plain
|rect|filled--bg, so it masks the floor exactly as a fixture does.
The tint is not part of the family either. Five type rules make this a furniture plan — sleeping and seating in purple, dining in rose, plumbing and white goods in blue — while the shell stays the black poché a plan expects.
Dimensioning to the faces
|floorplan#flat| "UNIT 3B" { unit: m; scale: 0.02; } [
|wall#outer| {
draw: move(0, 0) right(7):north down(4.6):east left(7):south close():west;
} [
|door#entry| { on: east; at: 3.4; width: 0.95; swing: right; }
]
|partition#bedwall| { draw: move(2.8, 0) down(4.6):side; } [
|door| { on: side; at: 1.9; width: 0.8; }
]
outer:west-in (-) bedwall:side-out { side: top; }
bedwall:side-in (-) outer:east-in { side: top; }
outer:west-in (-) outer:east-in { side: top; }
outer:north-in (-) outer:south-in { side: right; }
]
Every named wall run derives two face anchors — its offset edges:
name-in— the enclosed side on a closed loop, or the left of the pen’s travel on an open one.name-out— the other face.
That distinction is what a plan publishes: a room’s clear span, face to face. Never a centreline, and never a span that runs through a wall.
bedwall:side was drawn southward, so its -out face is the western one — the
left-hand room’s — and its -in face the eastern one. Read them the other way
round and each dimension eats the partition: the two would sum past the overall
instead of 2.65 + 0.1 + 4.05 = 6.8.
Dimensions sharing a side pack into rows, which is what keeps the two room
clears inside the shell’s own overall. |floorplan| is the container form, and
its label lowers to a title under the plan.
The whole parts library
{
density: 4; font-size: 19;
|head::block| {
layout: flow; align: center; font-size: 22; letter-spacing: 1;
}
|cap::block| { layout: flow; align: center; color: --muted; }
}
|floorplan#parts| { unit: m; scale: 0.02; } [
// ── Walls. A `|wall|`'s `draw:` is the **centreline**; `thickness:` offsets
// it half either way into the outline that takes the paint — mitred at the
// corners, bevelled past miter limit 4, concentric round an `arc()`.
|head| { translate: -3.7 0; } [ "WALLS" ]
|wall| { draw: move(-1.2, 0) right(2.4); }
|partition| { draw: move(1.5, 0) right(2.4); }
|wall| { draw: move(4.2, 0) right(2.4); thickness: 0.4; }
|wall| {
draw: move(6.9, 0) right(2.4);
fill: --bg; stroke: --stroke-dark; stroke-width: 1;
}
|wall| {
draw: move(9.6, 0) right(2.4);
fill: hatch(45); stroke: --stroke-dark; stroke-width: 1;
}
|wall| { draw: move(12.9, -0.5) right(1.2) down(1); }
|wall| { draw: move(15.1, 0.25) arc(2.2, 0, 1.8); }
|cap| { translate: 0 1.3; } [ "solid · 200" ]
|cap| { translate: 2.7 1.3; } [ "|partition| · 100" ]
|cap| { translate: 5.4 1.3; } [ "thickness: 0.4" ]
|cap| { translate: 8.1 1.3; } [ "fill: --bg" ]
|cap| { translate: 10.8 1.3; } [ "hatch(45) section" ]
|cap| { translate: 13.5 1.3; } [ "mitred corner" ]
|cap| { translate: 16.2 1.3; } [ "arc · concentric" ]
// ── Doors. `hinge:` picks the jamb by the segment's draw direction and
// `swing:` the side the leaf opens toward — `left` is the left of the pen's
// travel, so on these east-running walls it opens north. The chrome is
// generated: a leaf at 90° open plus its quarter arc, two mirrored halves for
// `double`, and two overlapping panels for `sliding`, which hangs no leaf. An
// opening's own text is its **schedule tag**, seated beside the gap on the
// face the leaf never sweeps — the last cell wears one.
|head| { translate: -3.7 2.9; } [ "DOORS" ]
|wall| { draw: move(-1.2, 2.9) right(2.4):run; } [
|door| { on: run; at: 0.75; hinge: start; swing: left; }
]
|wall| { draw: move(1.5, 2.9) right(2.4):run; } [
|door| { on: run; at: 0.75; hinge: start; swing: right; }
]
|wall| { draw: move(4.2, 2.9) right(2.4):run; } [
|door| { on: run; at: 0.75; hinge: end; swing: left; }
]
|wall| { draw: move(6.9, 2.9) right(2.4):run; } [
|door| { on: run; at: 0.75; hinge: end; swing: right; }
]
|wall| { draw: move(9.6, 2.9) right(2.4):run; } [
|door| { on: run; at: 0.45; width: 1.5; symbol: double; }
]
|wall| { draw: move(12.3, 2.9) right(2.4):run; } [
|door| { on: run; at: 0.3; width: 1.8; symbol: sliding; }
]
|partition| { draw: move(15, 2.9) right(2.4):run; } [
|door| "D1" { on: run; at: 0.75; }
]
|cap| { translate: 0 4.25; } [ "start · left" ]
|cap| { translate: 2.7 4.25; } [ "start · right" ]
|cap| { translate: 5.4 4.25; } [ "end · left" ]
|cap| { translate: 8.1 4.25; } [ "end · right" ]
|cap| { translate: 10.8 4.25; } [ "symbol: double" ]
|cap| { translate: 13.5 4.25; } [ "symbol: sliding" ]
|cap| { translate: 16.2 4.25; } [ "|partition| · schedule tag" ]
// ── A window draws its sill pair across the gap at the thickness's thirds;
// openings share a run freely as long as they never overlap. A flight draws
// `steps` treads at a 250 mm going, plus the up arrow.
|head| { translate: -3.7 6; } [ "WINDOWS · STAIRS" ]
|wall| { draw: move(-1.2, 6) right(2.4):run; } [
|window| { on: run; at: 0.6; }
]
|wall| { draw: move(1.5, 6) right(2.4):run; } [
|window| { on: run; at: 0.2; width: 2; }
]
|partition| { draw: move(4.2, 6) right(2.4):run; } [
|window| { on: run; at: 0.6; }
]
|wall| { draw: move(6.9, 6) right(2.4):run; } [
|door| { on: run; at: 0.2; width: 0.7; }
|window| { on: run; at: 1.2; width: 1; }
]
// the two flights stand on the row's baseline, so every caption in the row
// sits on one line however tall the piece above it is
|stairs| { steps: 4; translate: 10.8 6.6; }
|stairs| { steps: 8; translate: 13.5 6.1; }
|cap| { translate: 0 7.35; } [ "1200 default" ]
|cap| { translate: 2.7 7.35; } [ "width: 2" ]
|cap| { translate: 5.4 7.35; } [ "in a |partition|" ]
|cap| { translate: 8.1 7.35; } [ "two on one run" ]
|cap| { translate: 10.8 7.35; } [ "steps: 4" ]
|cap| { translate: 13.5 7.35; } [ "steps: 8" ]
// ── The furniture. Every body is one path on the fixture's own node, filled
// `--bg` so it masks the floor under it; the smart label reads **beside** the
// body, like a discrete's value — so a row seats its pieces on one baseline
// and their labels line up without a caption being written at all. A bed is
// named for its mattress: `queen` is what a bare `|bed|` draws.
|head| { translate: -3.7 9.4; } [ "BEDS" ]
|bed| "queen" { translate: 0 9.4; }
|bed| "king" { symbol: king; translate: 2.7 9.4; }
|bed| "double" { symbol: double; translate: 5.4 9.45; }
|bed| "single" { symbol: single; translate: 8.1 9.4; }
// One anatomy at four widths — outline, then the run tracing arm → back →
// arm; `one` is the armchair, `corner` folds the same two strokes round an L.
// The bar `stool` is the family's one-stroke member: a plain round seat.
|head| { translate: -3.7 12.2; } [ "SOFAS" ]
|sofa| "three" { translate: 0 12.95; }
|sofa| "two" { symbol: two; translate: 2.7 12.95; }
|sofa| "one" { symbol: one; translate: 5.4 12.95; }
|sofa| "corner" { symbol: corner; translate: 8.1 12.2; }
|sofa| "stool" { symbol: stool; translate: 10.8 13.225; }
// A `|dining|` is sized by its **tabletop**; the chairs stand a small
// pull-back off its edge, and that pair of rows is what extends the bbox.
|head| { translate: -3.7 15.4; } [ "DINING" ]
|dining| "six" { translate: 0 15.445; }
|dining| "four" { symbol: four; translate: 2.7 15.495; }
|dining| "round" { symbol: round; translate: 5.4 15.395; }
// The sanitaryware. A toilet is **one** silhouette — the cistern's shoulders
// flowing into the rounded pan — and `double-sink` is one unit with two
// basins, the kitchen run's, never two sinks side by side.
|head| { translate: -3.7 17.45; } [ "BATH" ]
|bath| "tub" { translate: 0 17.525; }
|bath| "shower" { symbol: shower; translate: 2.7 17.45; }
|bath| "toilet" { symbol: toilet; translate: 5.4 17.7; }
|bath| "sink" { symbol: sink; translate: 8.1 17.7; }
|bath| "double-sink" { symbol: double-sink; translate: 10.8 17.675; }
// ── …and the kitchen boxes, whose label is the one that centres **in** the
// body — the F / W / DW convention every real plan writes.
|head| { translate: -3.7 18.9; } [ "APPLIANCES" ]
|appliance| { symbol: stove; translate: 0 18.9; }
|appliance| "F" { symbol: fridge; translate: 2.7 18.9; }
|appliance| "W" { symbol: washer; translate: 5.4 18.9; }
|appliance| "DW" { symbol: dishwasher; translate: 8.1 18.9; }
|cap| { translate: 0 19.55; } [ "stove" ]
|cap| { translate: 2.7 19.55; } [ "fridge" ]
|cap| { translate: 5.4 19.55; } [ "washer" ]
|cap| { translate: 8.1 19.55; } [ "dishwasher" ]
]
Every wall look, every door pose and symbol, the windows, the flights, and all
six fixture families with every symbol: variant — drawn at 1:50, so the sizes
here are the real ones.
A floorplan is a drawing scope, so there is no track grid to lay a catalogue
on: every piece is datum-placed with translate: and every wall is drawn where
it stands. The true-size law does the rest — a tub is 1700 × 750 mm and a door
left to itself is 900 mm wide, whatever the file drafts in.
Printed as a blueprint
{
density: 3; clearance: 16; padding: 40;
fill: #00509e; color: #edf5fb;
--stroke-dark: white; --stroke-light: rgba(255, 255, 255, 0.72);
--caption-color: rgba(255, 255, 255, 0.72);
--footer-color: rgba(255, 255, 255, 0.72);
// every body that masks the floor takes the paper too
|slab::rect| { fill: #00509e; stroke: --stroke-dark; stroke-width: 1; }
|bed| { fill: #00509e; }
|sofa| { fill: #00509e; }
|dining| { fill: #00509e; }
|bath| { fill: #00509e; }
|appliance| { fill: #00509e; }
|room::block| {
layout: flow; direction: column; align: center; gap: 2;
font-size: 11; letter-spacing: 1;
}
}
|floorplan#unit| "UNIT 12A — ONE BEDROOM · 65 m²" { unit: m; scale: 0.02; } [
|wall#outer| {
draw: move(0, 0) right(9.6):north down(6.8):east left(9.6):south
close():west;
} [
|door#entry| "D1" { on: east; at: 1.2; width: 0.95; swing: right; }
|door#slider| "D2" { on: west; at: 3.4; width: 2.4; symbol: sliding; }
|window#w1| "W1" { on: west; at: 0.5; width: 2; }
]
// north and south are party walls against the neighbouring units, so they
// carry no openings at all — every one a condo unit gets is on the entry
// side or the balcony side
|partition#bedwall| { draw: move(0, 3.8) right(4.6):head down(3):side; } [
|door#bed-door| "D3" { on: side; at: 0.4; width: 0.9; swing: right; }
]
|partition#laundry| { draw: move(6, 6.8) up(0.8); }
// a stub off the south wall: with the bathroom's own west wall it makes the
// alcove the stacked washer/dryer is built into
|partition#bathwall| { draw: move(9.6, 4) left(2.9):head down(2.8):side; } [
|door#bath-door| "D4" { on: side; at: 1; width: 0.8; hinge: end; }
]
// Casework is plain geometry: counters, the island, the wardrobe, the deck.
|slab#deck| { width: 1.4; height: 6.6; translate: -0.8 3.4; }
|slab| { width: 3.9; height: 0.6; translate: 7.55 0.4; }
|slab| { width: 2; height: 0.9; translate: 7.2 2.05; radius: 4; }
|slab| { width: 1.1; height: 0.55; translate: 2 1.75; }
|slab| { width: 0.45; height: 0.45; translate: 0.6 4.075; }
|slab| { width: 0.45; height: 0.45; translate: 3 4.075; }
|slab| { width: 0.6; height: 1.4; translate: 4.25 6; }
|slab| { width: 1.2; height: 0.55; translate: 7.35 4.325; }
|appliance| { symbol: stove; translate: 6.4 0.4; }
|appliance| "F" { symbol: fridge; translate: 8.1 0.4; }
|appliance| "DW" { symbol: dishwasher; translate: 7.7 2.05; }
|appliance| "W/D" { symbol: washer; translate: 6.35 6.4; }
|bath| { symbol: double-sink; translate: 6.8 2.05; }
|sofa| { symbol: stool; translate: 6.7 2.75; }
|sofa| { symbol: stool; translate: 7.2 2.75; }
|sofa| { symbol: stool; translate: 7.7 2.75; }
|sofa| { translate: 2 0.55; }
|dining| { symbol: four; translate: 4.3 2.2; }
|bed| { translate: 1.8 4.85; }
|bath| { symbol: sink; translate: 7.35 4.325; }
|bath| { symbol: toilet; rotate: 180; translate: 9.15 5; }
|bath| { symbol: tub; translate: 7.6 6.325; }
|bath| { symbol: shower; translate: 9.05 6.25; }
|room| { translate: 2.2 2.75; } [ "LIVING / DINING" "23.9 m²" ]
|room| { translate: 7.4 3.2; } [ "KITCHEN" ]
|room| { translate: 3.3 6.1; } [ "BEDROOM" "12.8 m²" ]
|room| { translate: 5.65 4.6; } [ "HALL" ]
|room| { translate: 8.35 5.55; } [ "BATH" "7.3 m²" ]
|room| { translate: -0.8 3.4; } [ "BALCONY" ]
// The north arrow a plan needs is a |sketch| define — the parts-library
// escape, since the pen already draws it.
|sketch#north| {
draw: move(-0.8, 5.73) line(0.22, 0.72) line(-0.22, -0.2) line(-0.22, 0.2)
close();
fill: --stroke-dark; stroke: none;
}
|room| { translate: -0.8 5.46; } [ "N" ]
outer:west-in (-) outer:east-in { side: top; }
outer:west-in (-) bedwall:side-out { side: bottom; }
bedwall:side-in (-) outer:east-in { side: bottom; }
outer:north-in (-) bathwall:head-out { side: right; }
bathwall:head-in (-) outer:south-in { side: right; }
outer:north-in (-) outer:south-in { side: right; }
]
The plan is the one the reference implementation ships as samples/floorplan.lini,
untouched below the stylesheet — what makes it a print is the handful of visual
variables above it. --stroke-dark carries the poché and every dimension line,
--stroke-light the thin support lines, and the two caption tones follow. The
paper is stated as a literal rather than through --bg, because --bg names
the host page’s backdrop and a page that owns it would hand this figure white;
the fixtures take the same literal, since each one masks the floor with the
paper it sits on. The whole recipe is a built-in — lini --theme blueprint
prints any figure this way.
Go deeper
Floorplan — the architectural dialect covers wall junctions, hinge control, stairs and the full fixture table; the rest of section 15 is the engine underneath it.
Schematics
layout: schematic reads a figure as a circuit sheet: it seats the parts and
hands the wires to the orthogonal router, which lands them on pins, bends
square and dots the junctions.
A part, its pins and a net
{ layout: schematic; }
|component#U1| "AMS1117-3.3" [
|pin#vin| { side: left; number: 3; }
|pin#gnd| { side: bottom; number: 1; }
|pin#vout| { side: right; number: 2; }
]
|J#J1| "3V3 OUT" { pins: 2; cell: 2 1; }
U1.vout - J1.p1 "3V3"
|component| is the generic pin-bearing box. Its label is the part name and
its [ ] holds |pin| children, each drawing two things:
- The pin’s id, inside the body —
vin,gnd,vout. number:, outside beside the stub — the short lead the wire lands on.
|J| is the connector define: pins: 2 generates numbered pins in one column
facing the circuit, and cell: 2 1 seats it on the scope’s track grid as an
anchor. Without that, a one- or two-pin part is a satellite that hangs off
whichever wire touches it — which the next figure relies on.
A wire is an ordinary link, and its label is the net name, stood beside the trace rather than cut into it.
The sheet look arrives with the layout — green wires, pale bodies, muted pin numbers — riding theme variables, so this source paints nothing at all.
Discretes that seat themselves
{ layout: schematic; }
|component#U1| "AMS1117-3.3" [
|pin#vin| { side: left; number: 3; }
|pin#gnd| { side: bottom; number: 1; }
|pin#vout| { side: right; number: 2; }
]
|C#C1| "10u"
|C#C2| "22u"
U1.vin - C1 - |gnd|
U1.vout - C2 - |gnd|
U1.gnd - |gnd|
|C| is a discrete. It arrives with generated pins p1 and p2, its id is
its reference designator, and its label is the value.
Nothing here is positioned by hand. One- and two-pin parts are satellites rather than anchors: they take no cell of their own, and seat themselves at the pin their wire touches.
A chain passes through a two-pin part — U1.vin - C1 - |gnd| enters one pin
and leaves the other, so the whole decoupling leg is one line.
|gnd| is a built-in label capsule. A ground is drawn with its connection
point on top, which is why a chain ending in one grows downward to meet it.
Labels, flags and power nets
{
layout: schematic;
|v5::label| { symbol: power; } [ "5V" ]
}
|component#U7| "TMC2300" [
|pin#vs| { side: left; number: 18; }
|pin#gnd| { side: bottom; number: 1; }
|pin#step| { side: right; number: 4; }
|space|
|pin#diag| { side: right; number: 11; }
]
U7.vs - |v5|
U7.gnd - |gnd|
U7.step -> "STEP"
U7.diag - "DIAG"
A |label| is its own terminal — no pins and no dot-path — and a one-ended
wire mints one, seated at the pin.
The operator’s end marker sets the tag’s shape, and the two above differ for that reason alone:
->drawsSTEPin a right-pointing flag.-leavesDIAGuntagged — and a plain label is a run rather than a stop, so its box is the stretch of trace it names. That is whyDIAGis written over a wire instead of at the end of one.
A power net is a one-line define — |v5::label| { symbol: power; } [ "5V" ] —
and every |v5| capsule after it is that flag.
The |space| between the two right-hand pins is the gap a datasheet draws
between pin groups: no pin, just an empty rail slot, so the two tags have room
to stand apart.
A series leg and a junction
{
layout: schematic;
|v5::label| { symbol: power; } [ "5V" ]
}
|component#U1| "AMS1117-3.3" [
|pin#vin| { side: left; number: 3; }
|pin#gnd| { side: bottom; number: 1; }
|pin#vout| { side: right; number: 2; }
]
|J#J1| "3V3 OUT" { pins: 2; cell: 2 1; }
|R#R1| "1k"
|LED#D1| "PWR"
U1.vin - |v5|
U1.gnd - |gnd|
U1.vout - J1.p1 "3V3"
U1.vout - R1 - D1 - |gnd|
J1.p2 - |gnd|
Two statements naming U1.vout make a junction there rather than a
pass-through, and the router dots the fork where three wire ends meet.
The second is a series chain — U1.vout - R1 - D1 - |gnd| — through the
resistor and the LED, each entered on one pin and left on the other. The chain
hangs off the wire leaving the pin and grows toward its ground.
Both discretes carry ids on purpose: a minted reference designator is display-only, so give a part a name when you need to wire it.
The type is the reference family — |R|, |C|, |L|, |D|, |LED|,
|Q|, |SW| and the rest — and symbol: picks a variant, so
|Q| { symbol: pnp } swaps the transistor.
The whole part family
// The schematic part family, at one glance — every discrete, every `symbol:`
// variant, every net-tag shape. Nothing is wired: a schematic scope places
// anchors on its own **track grid**, so `cell: c r` (ordinal, empty tracks
// collapsing) lays the catalogue out row by row. The smart label is a part's
// **value**; its id is drawn verbatim as the reference designator, and an
// anonymous part mints a display-only one from its type — R1, C1, D1…
{
layout: schematic; gap: 40;
// A row header is an ordinary box on the track grid — a `|caption|` would
// pin itself out of the flow and take no cell [SPEC 5].
|head::block| { font-size: 13; color: --muted; padding: 0 8; }
}
// The potentiometer is a three-terminal `|R|`: its wiper `w` stands on the
// body's own centre x, the two ends on its centre y [SPEC 16.3].
|head| "Passives" { cell: 1 1; }
|R#R1| "10k" { cell: 2 1; }
|R#R2| "10k" { cell: 3 1; symbol: pot; }
|R#R3| "10k" { cell: 4 1; symbol: ntc; }
|C#C1| "100n" { cell: 5 1; }
|C#C2| "47u" { cell: 6 1; symbol: polarized; }
|L#L1| "4u7" { cell: 7 1; }
|FB#FB1| "600R" { cell: 8 1; }
|F#F1| "1A" { cell: 9 1; }
|Y#Y1| "16M" { cell: 10 1; }
|head| "Diodes" { cell: 1 2; }
|D#D1| "1N4148" { cell: 2 2; }
|D#D2| "5V1" { cell: 3 2; symbol: zener; }
|D#D3| "SS34" { cell: 4 2; symbol: schottky; }
|D#D4| "SMAJ24" { cell: 5 2; symbol: tvs; }
|LED#D5| "green" { cell: 6 2; }
// One ring for every three-terminal body: the FETs wear the BJT's.
|head| "Transistors" { cell: 1 3; }
|Q#Q1| "BC847" { cell: 2 3; }
|Q#Q2| "BC857" { cell: 3 3; symbol: pnp; }
|Q#Q3| "2N7002" { cell: 4 3; symbol: nfet; }
|Q#Q4| "DMP2" { cell: 5 3; symbol: pfet; }
|opamp#U1| "TLV9061" { cell: 6 3; }
// A flip is a pose too [SPEC 16.1]: `mirror: x-axis` swaps the collector and
// emitter rows, the way a sheet stands a high-side transistor.
|Q#Q5| "BC847" { cell: 7 3; mirror: x-axis; }
|head| "Sources & switches" { cell: 1 4; }
|V#V1| "5V" { cell: 2 4; }
|V#V2| "230V" { cell: 3 4; symbol: ac; }
|I#I1| "20m" { cell: 4 4; }
|I#I2| "20m" { cell: 5 4; symbol: ac; }
|BT#BT1| "3V" { cell: 6 4; }
|BT#BT2| "12V" { cell: 7 4; symbol: battery; }
|SW#SW1| "ON" { cell: 8 4; }
|SW#SW2| "RST" { cell: 9 4; symbol: push; }
// A `|TP|` carries one pin — legal, and what a probe pad is [SPEC 16.2].
|head| "Electromechanical" { cell: 1 5; }
|M#M1| "5V fan" { cell: 2 5; }
|BZ#BZ1| "85dB" { cell: 3 5; }
|TP#TP1| "VBUS" { cell: 4 5; }
// A `|label|` is its own terminal — the net tag. `symbol:` swaps in a drawing
// from the schematic set; `shape:` picks the outline the text rides in.
|head| "Net tags" { cell: 1 6; }
|label| "NRST" { cell: 2 6; }
|label| "TX" { cell: 3 6; shape: right; }
|label| "RX" { cell: 4 6; shape: left; }
|label| "SDA" { cell: 5 6; shape: both; }
|label| "CLK" { cell: 6 6; shape: round; }
|label| "VBUS" { cell: 7 6; symbol: power; }
|label| "RF" { cell: 8 6; symbol: antenna; }
|head| "Grounds" { cell: 1 7; }
|gnd| { cell: 2 7; }
|label| { cell: 3 7; symbol: earth; }
|label| { cell: 4 7; symbol: chassis; }
|nc| { cell: 5 7; }
Every discrete, every symbol: variant and every net-tag shape, on one sheet.
Nothing there is wired — a schematic scope places its anchors on a track
grid, so cell: c r lays a catalogue out row by row exactly as it seats a
part in a circuit.
Two things worth noticing in it:
- The smart label is the part’s value, and its id is drawn verbatim as the reference designator. An anonymous part mints a display-only one from its type — R1, C1, D1 — which is the rule the section above leaned on.
- A pose is a property.
mirror: x-axison the last transistor swaps its collector and emitter rows, the way a sheet stands a high-side part.
Go deeper
The full schematic reference covers the
discrete families and their variants, the track grid and cell:, rotation,
label shapes and the wire laws.
Expressions
Every number in a file can be computed. Parentheses hold compile-time math, the stylesheet binds names, and the result bakes into the SVG as a literal.
Math where a number goes
{
gap: 24;
|box| { fill: --teal-wash; stroke: --teal-deep; }
}
|box#a| "8 * 2" { padding: (8 * 2); }
|box#b| "sqrt(2) * 60" { width: (sqrt(2) * 60); height: 60; }
|box#c| "clamp" { width: clamp(30, 200, 120); height: 60; }
A ( … ) group is folded away when the file compiles. Inside it you get:
- Arithmetic —
+ - * / ^, and comparisons. - A ternary —
cond ? a : b. - The math library —
sqrt,abs,sin,cos,min,max,clamp,floor,round,pow,ln,exp.
A bare call like clamp(30, 200, 120) needs no group, because a call’s own
parentheses already count as one.
The parens are where operators live for a reason: outside them - is a link
and < is a marker, so the group is what tells the two apart.
Bindings
{
direction: column; gap: 16; align: start;
unit = 60;
step(n) = (unit * 1.4 ^ n);
|bar::box| { height: 34; fill: --amber-wash; stroke: --amber-deep; }
}
|bar| "step(0)" { width: step(0); }
|bar| "step(1)" { width: step(1); }
|bar| "step(2)" { width: step(2); }
|bar| "step(3)" { width: step(3); }
name = value; in the stylesheet binds a scalar; name(params) = ( … ) binds
a function. Both read anywhere a value goes, bare or inside a group.
The two signs do different jobs, and the difference matters:
=binds at compile time. A binding is a number the whole file shares, and it is gone by the time the SVG exists.:sets a live property. That is what becomes a CSS variable.
So the four bars above are one geometric series, and nothing downstream can tell they were ever computed.
Parametric geometry
{
direction: column; gap: 20;
wave(a, f) = (u * 320, a * sin(2 * pi * f * u));
}
|line#sine| { points: (u * 320, 24 * sin(2 * pi * 3 * u)); samples: 64; stroke: --sky-deep; }
|line#named| { points: wave(18, 2); samples: 64; stroke: --rose-deep; }
points: on a |line| or |poly| may be an expression in u. That variable
sweeps 0 → 1, sampled samples: times into a vertex list — which is how you
get a wave, a spiral or a curve with no vertex typed.
A bound function returning a point keeps the formula named, as wave(a, f)
does for the rose line.
Charts bind x the same way for a series’ fn: — Charts has it.
Locals
{ gap: 30; }
|line#hex| {
points: (r = 40; n = 6; a = 2 * pi * round(u * n) / n; (r * cos(a), r * sin(a)));
samples: 7; fill: --purple-wash; stroke: --purple-deep;
}
|line#oct| {
points: (r = 40; n = 8; a = 2 * pi * round(u * n) / n; (r * cos(a), r * sin(a)));
samples: 9; fill: --sky-wash; stroke: --sky-deep;
}
Inside a group, name = expr; binds a local for the rest of that group, and
the final expression is its value — no keyword and no return. A top-level
comma makes that value a point.
So one expression draws a regular polygon: n corners at radius r, sampled
once per corner and once more to close it.
Everything here bakes. An exported SVG never depends on host CSS for a size.
Go deeper
Colour, variables & expressions has the full grammar — constants, scientific notation, and what a value may be.
Style, colour & themes
Every figure in the tour is dressed by the same cascade, so what this page shows about a chain of boxes holds for a chart, a sequence or a floor plan.
What the defaults already say
|box#ingest| "Ingest"
|box#queue| "Queue"
|cyl#store| "Store"
ingest -> queue "raw"
queue -> store "rows"
No stylesheet — and the figure still has a fill, a stroke colour, a corner radius, a font, a gap and a wire weight.
Those are the templates’ baked defaults: the bottom tier of the cascade, and what every section below is a way of overriding. A figure that says nothing about style is not unstyled.
One block at the root
{
gap: 44; padding: 26;
fill: --bg; color: --sky-ink; font-size: 13;
}
|box#ingest| "Ingest"
|box#queue| "Queue"
|cyl#store| "Store"
ingest -> queue "raw"
queue -> store "rows"
Declarations written straight into the root { } are the scene’s own
properties. padding is the SVG margin, and fill: --bg paints the backdrop a
figure otherwise leaves transparent.
The text family behaves differently from the rest: color, font-size,
font-family and font-weight inherit down the tree, nearest ancestor
winning. That is why both link labels take their colour and size from a root
that never mentions them.
Captions and link labels size themselves as fractions of the inherited
number, so one font-size at the root rescales the whole scene in proportion.
The ladder
{
gap: 44;
|box| { radius: 0; }
.stage { fill: --amber-wash; stroke: --amber-deep; color: --amber-ink; }
#store { stroke-width: 3; }
}
|box#ingest| "Ingest" .stage
|box#queue| "Queue" .stage
|cyl#store| "Store" .stage { fill: --gray-wash; }
ingest -> queue "raw"
queue -> store "rows"
A selector is a type (|box|, or |-| for links), a class, an id, or a
space-separated run of those for descendants (|group| |box|).
They stack in five tiers, low to high:
- Type rules —
|box| { } - Descendant rules —
|group| |box| { } - Classes —
.stage { } - The id rule —
#store { } - The node’s own
{ }
Most specific wins per property, and ties go to whichever was written later.
Follow the cylinder through all five and you can read the figure: it keeps its
corners because |box| never matched it at all, takes its ink from .stage,
its stroke weight from #store, and its grey from its own block.
Colour from the palette
{
gap: 44;
--brand: #6d5bd0;
|svc::box| { fill: --teal-wash; stroke: --teal-deep; color: --teal-ink; }
|-| { stroke: --gray-deep; }
#store { fill: --brand; stroke: --brand; color: white; }
}
|svc#ingest| "Ingest"
|svc#queue| "Queue"
|svc#store| "Store"
ingest -> queue -> store
Eleven hues — red rose orange amber lime green teal sky blue purple gray —
each carrying five tiers: wash, soft, the bare name, deep, ink.
They are named for the job they do, not for their lightness. Every colour
is a light-dark() pair, so a pale surface in light mode has to become a dark
one in dark mode — and a name like “light” would invert and start lying.
--teal-wash is the faint surface either way.
Wash fill, deep stroke, ink text is the recipe worth reaching for; most figures in this tour are exactly that.
--brand is your own variable. --name covers colours and the font family,
since sizes bake at compile time.
Gradients, hatches and shadows
{ gap: 30; }
|box#g| "gradient" { fill: gradient(--rose, --sky); color: white; stroke: none; }
|box#h| "hatch" { fill: hatch(45, 6, --gray-deep); }
|box#s| "shadow" { shadow: 3; }
|box#m| "multiple" { multiple: 3; fill: --lime-wash; stroke: --lime-deep; }
Four ways to make a fill say more than a colour does:
gradient(a, b)blends two hues at a flattering angle, on a fill or a stroke.linear-gradient(angle, …)andradial-gradient(…)are the control gates when you want the angle yourself.hatch(angle, pitch, colour)is the drafting section texture, and works on any fill.shadowdrops a soft shadow.multipledraws offset duplicates behind a node — the “several of these” reading.
Dark mode and themes
{ gap: 40; fill: --bg; padding: 24; }
|box#a| "Ingest" { fill: --teal-wash; stroke: --teal-deep; color: --teal-ink; }
|box#b| "Store" { fill: --amber-wash; stroke: --amber-deep; color: --amber-ink; }
a -> b
Toggle this book’s theme and the figure follows. Every colour Lini emits is a
light-dark() pair keyed on color-scheme, so one SVG carries both
palettes and follows the viewer’s OS with no script at all.
data-theme="dark"on any ancestor forces one.- At export,
lini --theme darkpins a palette —light,high-contrastandblueprinttoo, or a CSS file of your own--lini-*overrides. lini theme NAMEprints one to start from.
Everything sits in @layer lini.defaults, so a host page re-themes a figure
with plain CSS and no !important:
.lini { --lini-accent: #ff6600; --lini-font-family: Inter, sans-serif; }
Go deeper
Selectors, cascade & specificity has the full ladder, paint, stroke & text the property behaviour, and colour, variables & expressions the palette, gradients, hatches and the variable rules.
Tooling
One binary, and everything below is a subcommand or a flag of it.
{
gap: 110; clearance: 22;
|box| { fill: --purple-wash; stroke: --purple-deep; color: --purple-ink; }
|out::box| { fill: --rose-wash; stroke: --rose-deep; color: --rose-ink; }
|-| { font-size: 11; stroke: --gray-deep; }
.round { stroke: --teal-deep; color: --teal-ink; }
}
|box#src| "figure.lini" [
|icon| "file-text" { fill: none; stroke: --purple-deep; width: 20; height: 20; }
]
|box#cli| "lini" { font-weight: bold; font-size: 16; }
|column#outs| { gap: 30; } [
|out#svg| "SVG"
|out#html| "HTML"
]
src -> cli "compile"
cli -> outs.svg & outs.html
cli ~> src "fmt · desugar" .round
Text in, a figure out — and fmt and desugar are the arrow back, rewriting
the text you started from.
The loop
lini figure.lini -o figure.svg # compile
lini serve figure.lini # live preview at localhost:7700
lini serve samples/ # a folder: pick, edit and render in the browser
lini --check --strict figure.lini # the full compile, nothing written; warnings fail
echo "a -> b -> c" | lini - # stdin to stdout
serve does two different things, depending on what you point it at:
- A file — live-reloads it on every save.
- A directory — opens the playground, the same editor as lini.rs/play: source left, figure right.
--check runs everything a compile runs and writes nothing. With --strict,
which turns every warning into a failure, that is the gate to put in CI.
Errors that say what to do
figure.lini:12:9: error: link endpoint 'kitchen.bowl' not found at scene root;
did you mean 'kitchen.counter.bowl'?
Every diagnostic is file:line:col, carries a stable code, and suggests a fix
— an unknown property names the nearest one, an unknown endpoint the full path
that does exist.
--json emits the same diagnostics as a document with spans and
machine-applicable edits, which is what an editor integration reads.
Nothing is dropped silently. A property that cannot apply where it is written is an error, not a no-op — so a figure never quietly ignores you.
Export
lini --static figure.lini -o figure.svg # self-contained: no var(), text as paths
lini --embed-font figure.lini -o figure.svg # @font-face inlined, browser-only
lini --theme dark figure.lini -o dark.svg # one palette pinned
lini --format html figure.lini -o figure.html
By default an SVG carries font names and live CSS variables — the embeddable form, which a host page can re-theme. Two flags trade that away for portability:
--staticinlines every variable and outlines the text to paths, so the file renders identically in resvg, librsvg, an email client or a PDF pipeline. Every figure in this book is built this way.--embed-fontkeeps text as text and inlines the used weights. Browsers only — resvg and librsvg ignore@font-face.
Format and desugar
lini fmt figure.lini # canonical style, in place
lini fmt --check figure.lini # exit 1 if it would change anything
lini desugar figure.lini # the file with every template and shorthand expanded
fmt is the one style, and it is not configurable: two-space indent,
declarations grouped on a line, children one per line, table cells padded into
columns.
desugar prints the engine’s true input — a |table| as its grid of
|cell|s, a tree’s generated branch links, a mindmap’s palette rules. When
the sugar surprises you, the expansion is where the answer is.
Highlighting and books
lini highlight figure.lini # the source as <span class="lini-tok-…"> HTML
lini highlight --css # the palette those spans wear
One scanner colours every listing — this book, the site, the playground, the
VS Code and Zed extensions — from the same word lists the compiler reads.
mdbook-lini is the preprocessor
behind these pages: ```lini fences compile to inline SVG at build time,
with the source a click away.
```lini
|chart| "Signups" { categories: "Jan", "Feb", "Mar"; } [
|line| { data: 14, 19, 26; curve: smooth; marker: dot; }
]
```
The same fence, and the same words after it, work in an Astro site: astro-lini is the integration, and both ride on lini-wasm — the compiler as an npm package, which is what to reach for to build a third.
For agents
Anything that writes text can write a figure. The repository ships
SKILL.md, a
self-contained guide an agent can be handed, and
schema/lini.schema.json
— every type and property with its value shape, generated from the ledger the
compiler reads.
Go deeper
The CLI lists every flag, SVG output the structure and class hooks a host can style, and errors the diagnostic catalogue.
At a glance
cat -> dog -> bird
That’s a complete diagram: three boxes, two links. Lini fills in the rest.
| Form | Means |
|---|---|
|type#id| | Identity — a type, an optional #id. Always in bars: an instance (|oval#cat|), a rule (|oval| { … }), a define (|cat::oval| { … }). |
"…" | The label — what the node is called, placed by its type (text, a caption, a symbol, a chart title). |
{ … } | A style block — key: value; declarations. |
[ … ] | A content list — a node’s children. |
.name | A class — define it (.hot { … }), wear it (|box| .hot). |
#name | An id — declare it (|box#cat|), select it (#cat { … }), reference it bare (cat -> b). |
--name | A themeable variable (fill: --accent). |
a -> b | A link. |
Three defaults make small diagrams trivial:
- Omit the type →
|box|(a rounded, framed card);|#cat|is a default box. - Omit the label → the box is empty.
- Name an undeclared id in a link → it’s auto-created as a labelled
|box|(cat -> dogadds|box#cat| "cat").
A file is a stylesheet, then drawn statements. The stylesheet is one { } block at the
top — setup that draws nothing. After it come the instances and links, in source order
(usually instances first, then links — a layout: sequence reads the order as time, SPEC 13):
{ // the stylesheet — setup only
layout: grid; columns: repeat(3); gap: 30; // scene config
|box| { radius: 6; } // a rule — style every box
.hot { stroke-width: 2; } // a class
}
|box#server| "Server" .hot // the canvas, two instances
|box#client| "Client"
server -> client "requests" // a link, with a label
The language every node and every layout shares. Read top-to-bottom once; the layout sections (Part II) and the reference (Part III) build on it and never restate it.
1. Mental Model
A Lini file is the body of an implicit root container: a stylesheet of setup first, then the drawn canvas instances and links in source order — and every statement is exactly one of the three:
| Part | Holds | Drawn? |
|---|---|---|
| stylesheet | one { } block: scene config (incl. link & routing defaults), rules, classes, defines | no — it styles |
| canvas | instances — boxes (|type#id|) and text ("…") | yes |
| links | a -> b connections | yes |
One character tells a statement’s kind — a leading | opens identity (a
node — or, when a link operator follows the closed capsule, a capsule-headed
link, SPEC 9), a " text, a bare name a link, and inside the
stylesheet a ./#/|…| opens a rule. The capsule is self-delimiting, so
one token after it still decides; no prescan, no ambiguity.
Two brackets, one capsule, three sigils — one meaning each (the table in
At a glance, SPEC 2): |…| is identity, the only place a
type lives; { … } is style, the only place declarations live; [ … ] is
content, in source order. A drawn node is
|type#id| "label" .class { style } [ children ], only the bars required; a link
is the same tail on a different head: a -> b "label" .class { style } [ labels ].
A name goes bare only when referenced, and the one thing you reference is an
id; types and classes are never linked, so they are always sigil-marked.
Boxes and text, like HTML. A box has identity, classes, a style block, and
children. A string is text content — a leaf with no identity or children, though
it may wear classes and carry a style block ("x" .quiet { color: red },
SPEC 3). A
string in a box’s [ ] (or trailing the head as its label) is that box’s text; on
its own it is a free-standing text node. To give text children, a border, padding,
a pin, or a wirable id, wrap it in a box (|block| is the minimal one) — exactly
like wrapping a web page’s text in an element.
The file is the root container. The stylesheet { } is the root’s own setup
block; the canvas instances are its children (written bare — the file is its
[ ]); the links are its internal links. Scene properties (layout, gap,
padding, fill, font-size, clearance, routing, …) sit in that block, alongside
rules like |-| { stroke: … } for link look; inheritable ones (font-*, color,
clearance, routing) cascade to every node and link.
Render order is source order; the cascade is whole-file. Instances draw in the
order written (later on top, pinned children above the flow; layer: overrides),
and every rule applies to every instance. Links need no declaration: naming an id
declared nowhere auto-creates it (SPEC 3).
Two kinds of variable. Visual values that don’t affect layout — colours and
the font family — stay live CSS variables (--lini-fill, --lini-accent, …), so a
host page can re-theme them; each colour carries a built-in dark variant following
the viewer’s OS or a data-theme toggle (SPEC 10).
Layout values — sizes, gaps, paddings, widths, and font size — bake into the
SVG as literals: text is measured at compile time, so its size can never be a
runtime var(), and a standalone SVG always looks right.
2. Lexical Syntax
| Property | Value |
|---|---|
| Extension | .lini |
| Encoding | UTF-8 (BOM ignored) |
| Line endings | LF or CRLF (normalized on read) |
| Comments | // … to end of line. No block comments. |
| Statement end | A node/link/text statement ends at a newline or ;. A declaration ends at ; — its value runs to that ; (or a closing }), so a value may span lines. |
| Identifier | [a-zA-Z_][a-zA-Z0-9_-]* — case-sensitive, ASCII, dash-case |
Whitespace is insignificant except as a token separator and where a rule below says otherwise:
| Form | Whitespace rule |
|---|---|
|…| | Identity in bars: a type, an optional #id (|box#cat|), or an id alone (|#cat|). :: is the define operator (|cat::oval|). Bars are paired; surrounding space at the boundary is not allowed. |
#id | Inside the bars it names the node’s id; at a rule’s head it is an id selector (#cat { }). A # followed by hex digits in a value is a colour (#f80); the two never meet — one heads a statement or sits in bars, the other is a value. |
key: value | : separates name and value; surrounding space optional, canonical is one space after (radius: 5). |
a:side | A : after a link endpoint forces a side (a:left). Distinct from the declaration : by position — it follows an endpoint, never opens a value. |
.name (class) | At a rule head it is a class selector / definition (.hot { … }). On an instance, link, or text leaf it is a worn class, following the identity — spaced off it (|box| .hot, a -> b .loud, "x" .quiet), the rest of the chain glued (.hot.loud). |
id.child | No space — an endpoint path into a child (kitchen.bowl). |
--name | A variable, in a value or at a statement start to declare one. |
| link op | [marker?] line [marker?], glued, no internal space (->, --->, <->). |
[ … ] | A content list. Paired; whitespace inside is insignificant. |
Strings — double-quoted UTF-8: "…". Escapes: \", \\, \n, \t. A
double-quoted string is always text; leading and trailing whitespace in its value is
trimmed (" ABC " is “ABC”, and a spaces-only " " becomes ""), so source
spacing never leaks into the render.
Single quotes are not strings (reserved, SPEC 23).
A bare word is an identifier, never a string. In a value, an unquoted word is
always an identifier — a keyword, a colour or symbol name, a font-family, or an id
reference — so literal text is always quoted: a string-valued property (title,
hint, href, src, path) takes a "…" even with no spaces. The one hybrid is a name that
may contain spaces — font-family — bare or quoted, quoted only when needed
(font-family: "SF Mono"), as in CSS. Numbers and (…) expressions are bare too;
only text is quoted.
Expressions — a parenthesized region (…) is a compile-time math expression,
folded to a literal number (or a point); parentheses are the only place operators
appear, a call’s own parens count (up(5 * r, 10)), and groups may span lines
(SPEC 10.7).
Numbers — integer or decimal, optional sign, no units (px for lengths, degrees
for angles, 0–1 for opacities/fractions). 10, -5, 0.25, +3. A trailing %
makes a percentage (50%), valid only in colour components.
The comma law — CSS’s rule, stated once. A comma separates repeated
list items: data: 2, 3, 4 · columns: 80, 140, auto · points: 0 0, 10 10 ·
categories: "Q1", "Q2" · along: 0.2, 0.5, 0.8 · align: start, center, end.
A space separates the components of one item, tuple, interval, or
shorthand: padding: 5 2 5 5 · shadow: 2 2 4 #0003 · translate: 10 -4 ·
range: 0 100 · cell: 2 1 — and so data: 10 20, 30 40 is two x y points,
and a lone data: 10 20 is one point, never two values (a value list is
comma-separated: data: 9, 15, 24). A pipeline of calls that folds into one
value stays space-separated, like CSS transform — draw: move(0,0) up(8) fillet(2), and mirror:, whose items each reflect the union so far.
Functions use parentheses and sit in value position —
rgb(…), hsl(…), repeat(…), the math library, and any you bind
(SPEC 10). A call’s ( glues to its name
(rgb(…), never rgb (…)); a free-standing (…) is a math group, and a free-standing
(-), (o), or (<) a measuring op (SPEC 15.6) — which is how
move(-2, 5), (8 * 2), and pin (o) never meet.
Colours — #fff, #f80c, #ffaa00, #ffaa00cc (3/4/6/8 hex digits; the 4-
and 8-digit forms carry alpha), CSS names (red, cornflowerblue), rgb(…),
rgba(…), hsl(…), hsla(…) (percentages allowed — hsl(200, 50%, 50%)),
oklch(L, C, H[, A]) (the palette’s own space — L/A in 0–1, C the chroma, H in
degrees; folded to a hex at compile time, so it renders in every target), a
--name variable reference, or none. Out-of-range channels are an error. Beyond
a flat colour, a paint (fill / stroke / gap-fill) may be a gradient —
gradient(…), linear-gradient(…), or radial-gradient(…) — reached, like the
built-in hue palette, through the colour system (SPEC 10).
3. Statements & the Label
A file is a stylesheet, then drawn statements in source order (SPEC 1), and
a container’s body nests the same idea: a { } style block, then a [ ] of children and
internal links.
The stylesheet
One { } block at the very top of the file — optional, omitted when there is
nothing to set up. Unlike an ordinary style block (declarations only), it is the
root’s setup block, so it additionally holds the file-global definitions:
| Item | Form | Means |
|---|---|---|
| Scene config | layout: grid; routing: orthogonal; | a declaration on the root — clearance / routing cascade to every link (SPEC 9) |
| Variable | --brand: #f60; | a themeable visual variable (colour / font) |
| Binding | my_r = 5; scale(n) = (…) | a compile-time value / function, bound with = — read in any expression (SPEC 10) |
| Rule | |box| { … } | style every box (an element selector) |
| Link rule | |-| { stroke: #666; } | style every link — the |-| selector (SPEC 9) |
| Descendant rule | |table| |box| { … } | style every box inside a table |
| Class | .hot { … } | define class hot |
| Id rule | #hero { … } | style the one node with id hero |
| Define | |treat::box| { … } | a new type treat, base box, with its defaults |
{
gap: 16; fill: --bg;
--brand: #ff6600;
scale(n) = (100 * 1.2^n);
|box| { radius: 6; }
|-| { stroke: #666; }
.hot { stroke-width: 2; }
|treat::box| { radius: 5; }
}
|treat::box| reads “treat is a box”; the :: sets a define apart from a
plain reference (|box|) at a glance. Defines chain (|panel::treat|) and may
carry intrinsic children (SPEC 9). Max inheritance depth 16; cycles are an
error.
Node declaration
|type#id| [ "label" ] [ .class… ] [ { style } ] [ [ children ] ]
Only the bars are required — and at least a type or an #id must sit inside
them. SPEC 1 names the parts; classes follow the bars
(|box| .hot, |box| .hot.loud), never sit inside them.
|cyl#db| "Postgres" .primary { fill: #eef } [
|badge| "v16"
]
| Form | Effect |
|---|---|
|box#cat| "" | same as |box#cat| — "" is just an empty string. |
|box| "Load balancer" | anonymous labelled box (can’t be linked to). |
|#cat| | a default |box|, id cat. |
The label
A node has no label unless you give it one — a bare |box#cat| is an empty box
(the #cat is a handle, like HTML’s id=, not text):
| Label | Means |
|---|---|
| no string at all | nothing — an empty box |
"X" | the label “X” |
"" | an empty string — nothing in flow, an empty cell in a grid (SPEC 12) |
A link to an undeclared name still draws a labelled box (Implicit nodes).
A multi-word label needs no [ ] (|box#lb| "Load balancer").
The label is smart — each type places it, and every layout extends the same rule (a chart’s label is its title, a series’ its legend entry — SPEC 14), so no type needs a hand-written caption or symbol. Give no label and a type places nothing:
"X" on | becomes |
|---|---|
|box| and the shapes (|oval|, |hex|, |cyl|, |diamond|, …) | its centred text |
|group| / |table| | its caption (SPEC 8) |
|icon| / |sign| | its symbol — |icon| "heart" is |icon| { symbol: heart } |
| a link | a label along the route (SPEC 9) |
a |chart| / series / |axis| / participant / frame | its title / legend / axis title / header / guard (SPEC 13, SPEC 14) |
The label takes no style of its own. The { } and classes after the head are
the node’s, so a styled, classed, or nudged label rides the [ ] content form
instead, where each string is a leaf in its own right (Text content):
|box#api| "API" .hot { fill: red } // label + class + the node's own style
|box#api| [ "API" { translate: 0 -6 } ] // a styled label, via content
The label and [ ] coexist — one inline label only (two or more strings go in
the [ ]). The label is the node’s one inline item, lowered by its type — a text
or caption child prepended to the [ ], or (for |icon|/|sign|) the symbol —
and the [ ] holds the rest:
|group#kitchen| "Kitchen" [ |box#bowl| "Bowl" ] // caption + a child
|icon| "bell" [ "3" ] // symbol + a text badge
Text content
A string is a text node — always a <text> leaf, never wrapped:
- In a box’s
[ ](or as the box’s label) it is that box’s text — centred when it is the only in-flow child, else a flow child laid out by the box’slayout. - On its own (on the canvas, or in a
[ ]) it is a free-standing flow / canvas text node. - Several strings are several text nodes —
"a" "b" "c"is three (a string is self-delimiting, so no;is needed between them). - An empty
""is suppressed (adds no text) — except as a grid cell, where it holds its track (SPEC 12). - Multi-line text uses
\n(or wraps atmax-width— SPEC 5); the box sizes to the widest line, with afont-size × 1.2leading between lines (plus anyline-spacing), lines aligned by the container’s packing knob (SPEC 6).
A string carries no children — text is a leaf, not a box — but where it is
content (free-standing, or a child in a [ ]) it takes the node tail: it
may wear classes and carry a style block of text properties —
"Starter" .card-title, "X" { color: red; font-weight: bold; translate: 0 -6; rotate: 12 }. In its own block only text-valid properties apply (color /
fill, every font-*, opacity, letter-spacing, line-spacing,
text-transform, text-decoration, text-shadow, translate, rotate,
layer); any other — pin, padding, width, a border, children, even
href / hint — needs a real box, so wrap the text in a |block|. A worn
class is looser, per the class law (SPEC 4):
its text-valid declarations land, the rest are inert on the text wearer. Set on
the string the style applies to it directly; set on a containing box it cascades
down (SPEC 6).
Implicit nodes
A link endpoint that is a single bare id not present in the link’s scope
auto-creates the node |box#cat| "cat" in that scope — a box named cat, labelled
“cat” — so cat -> dog -> bird is a complete three-box diagram. The same holds inside
a container body: a body link auto-creates its missing endpoints among that body’s own
children. Declaring the id in the scope — before or after the link — uses it instead
of creating one. A path endpoint (kitchen.bowl) is never auto-created: it must
resolve to an existing node, or it is an error. If a same-named node exists elsewhere
in the tree, the box is still created here and a warning names the other match.
An auto-created id that is a near-miss of a name already known in its scope —
a small typo (edit distance ≤ 2, and shorter than the id itself), or equal
ignoring case, against the declared and the previously auto-created names —
warns toward the likely target: cta -> bird warns did you mean 'cat'?
even in an all-implicit file. Distinct names stay silent — short ids (a -> b)
and numbered siblings (server -> server2) are families, not typos — so
legitimate mixed use draws no noise (SPEC 21). Auto-create is
box-only; the typed declare-at-first-use is the capsule endpoint
(cat -> |cyl#db| — SPEC 9).
Declarations
A declaration key: value; lives only in a { } style block — the stylesheet
(configuring the root) or a node’s own block — and ends with ;, so a value may
span lines (SPEC 2); the ; is optional only immediately before
}. A bare key: value outside a { } is an error. Every property, its value shape,
and where it applies is in the Property Ledger.
4. Selectors, Cascade & Specificity
A rule is selector { declarations }. A selector is one or more
space-separated units; the space is the descendant combinator. A unit is a type
|box| (with an optional #id, |table#main|), the link type |-|, its
drawing subtype the dimension type (-) (SPEC 15.6), a class
.hot, or an id #hero:
|box| { … } // every box (element selector)
|-| { … } // every link — a line in the identity capsule ([SPEC 9](#9-links))
(-) { … } // every dimension — the |-| subtype ([SPEC 15.6](#156-dimensions))
.hot { … } // every node with class .hot
#hero { … } // the one node with id hero
|table| |box| { … } // every box inside a table (descendant)
#g |-| { … } // every link written in #g
.sidebar |box| { … } // every box inside a .sidebar
|table| .hot { … } // every .hot inside a table
A descendant selector matches a node (or link) whose ancestor chain contains each
unit in order (not necessarily adjacent), exactly like CSS’s descendant combinator.
Every construct keeps its sigil, so a selector reads as a run of marked units; a
bare word is never a selector. |-| and its dimension subtype (-) are
selector-only: a link is drawn by an operator, never instantiated (SPEC 9).
A type’s class never glues into its bars (|box.hot| is rejected): a class is
worn, not part of identity. To match boxes-with-a-class, style the class
(.hot { … }); to match within one, use a descendant (.hot |box|).
A define’s declarations (SPEC 3) are the new
type’s defaults — tier 1 below; its optional [ ] children materialize per
instance (SPEC 9).
Selecting vs. drawing is decided by the section, not the syntax. |box| .hot
in the stylesheet is a descendant rule (.hot inside a box); on the canvas it is
an instance (a box wearing .hot).
The cascade
Properties on a node merge by a fixed five-tier ladder — the more specific source wins, ties broken by later wins (source order). It is CSS-shaped but not CSS specificity: a descendant rule always loses to a class rule, whatever units it names. The tiers, low to high:
- Type cascade — walked from the base primitive up to the node’s declared type,
layering each type’s element-rule (
|box| { }) and define defaults. A more-derived type overrides what it builds on. (This is where a template’s and a define’s baked defaults live — SPEC 8.) - Descendant rules —
|table| |box| { },.sidebar |box| { }, matched against the ancestor chain. - Class rules —
.hot { }, worn via|box| .hoton the node (a text leaf wears them the same way —"x" .hot, SPEC 3). - Id rule —
#hero { }, the node’s own id. - The instance’s own block —
|box#client| { fill: white }— the most specific, beats everything above.
A link walks the same ladder — its type is |-|, its ancestors are its scope’s
container chain, it has no id: below tier 1 sit the baked link base plus the scope’s
clearance / routing, then the |-| element rule (type), descendant |…| |-| and worn-class
rules, then the link’s own block (SPEC 9). One exception: a link into a
node’s own descendant (x → x.path — containment, or a tree’s branch fan) cascades
as if written in x — its ancestor chain is x’s own, so #x |-| { } reaches it. A dimension is a link
subtype — type chain |-| → (-) — so a (-) { } rule beats |-| { } for
dimensions (the more-specific type, tier 1); (-) matches the measuring ops
only — a leader is styled through |-| (a leader-specific selector is
deferred, SPEC 24) (SPEC 15.6).
Complex values replace wholesale. The merge is per-property, not deep:
translate: x y or padding: t r b l on a higher tier replaces the whole value from a
lower one, never blending component-by-component. A pinned child ignores cell: —
pinning takes it out of the grid (SPEC 5).
Inheritable properties (the text family, color, clearance, routing) additionally
flow down the tree — nearest ancestor wins — independent of the specificity tiers
above (SPEC 6).
5. The Box Model
A node’s bounding box is the smallest axis-aligned rectangle containing it, stroke included.
- Centre origin. Every bbox is centred at the parent’s origin by default.
- Source order = render order; later draws on top, with pinned children above
the in-flow ones.
layer: Noverrides; ties break by source order. - Strokes count toward the bbox —
width: 100 height: 50 stroke-width: 4→ 104×54. Only painted strokes:stroke: nonepaints nothing and counts nothing, whateverstroke-widthsays — so a bare|block|(which keepsstroke-width: 2invisibly, SPEC 7) truly sizes to its content. |path|takes native top-left coordinates rather than a centred bbox. (A node’s origin may also sit off its bbox centre — a|sketch|’s pen origin, apattern:’s seed, a|drawing|’s datum — SPEC 12, SPEC 15.1.)- Rotation applies last as an SVG transform; the rotated bounding rectangle propagates upward.
pin — out of the flow
Every child is in flow by default — laid out by its container’s layout
(SPEC 11). pin lifts a child out, aligning the child’s
matching point flush with a named point of the parent:
pin: | The child sits… |
|---|---|
none (default) | — in flow; nothing is pinned |
center | centre on the parent’s centre |
top · bottom · left · right | flush against that parent edge |
top left · top right · bottom left · bottom right | with its corner on that parent corner |
The anchor is the parent’s drawn box — border and padding included, and the box it ends up with: a container grown after its interior was laid out (a stretched grid cell, a flex child filling the cross axis) re-seats its overlays on the grown box.
A pinned child is an overlay. It does not grow the parent — a parent of only
pinned children collapses to 2 × padding — and it paints above the in-flow
children, so a badge needs no explicit layer. The canvas always includes it, so an
overlay is never clipped. Set layer: to reorder overlapping pins, or to push one
beneath the flow.
translate and rotate — the universal nudge and turn
translate: x y shifts a node by (x, y) after it is placed. It works on
every node — flow children, pinned children, text nodes, the root alike — and is
layout-neutral: siblings don’t move, the parent doesn’t grow, no size changes. It is
CSS’s standalone translate, baked into the node’s origin (so a standalone SVG needs
no transform variable); the canvas still includes the shifted node.
There is no numeric coordinate property. Because the parent’s origin is its
centre, pin: center + translate: x y lands a child’s centre at parent-local
(x, y) — explicit coordinates with no node-size arithmetic.
rotate: N turns a node N degrees about its bbox centre, applied last as an SVG
transform. Like translate, it works on any node, text included. pin (which
needs a parent anchor and takes a child out of the flow) is a box job; to pin
text, wrap it in a |block|.
Auto-sizing
width and height default to auto — the bbox sizes to its content (text or
child nodes) plus padding on each side (default 20 on a framed box; there is no
separate text padding). Sizing is border-box: padding sits inside the box, never
added on top, and the two axes are independent. An explicit width / height is a
floor — the box is exactly that size when its content fits, and grows past it (to
content + padding) when the content is larger, so a box never clips or spills its
content. A box with no in-flow content — empty, or holding only pinned overlays —
has nothing to grow for: an explicit size stands exactly as written, and an auto
one falls to 2 × padding on each axis (the default padding 20 gives a 40 × 40
minimum).
Padding also places the content. The content area is the box inset by padding,
and the content sits within it; symmetric padding centres it, while an asymmetric
padding: t r b l offsets it — padding: 4 4 20 4 lifts the content toward the top,
away from the larger bottom inset, exactly like CSS.
max-width — wrap to fit
max-width: N caps a box’s auto width; text-wrap: wrap | nowrap (default
wrap) says whether text inside breaks into lines to honour the cap — both inert
without a finite max-width. Wrapping prefers whitespace and falls back to
breaking inside a word (grapheme boundaries), so the no-clip / no-spill law holds at
any width. The wrapped size is the measured size — it feeds auto-sizing, grid
tracks, gutters, spacing, link labels, and routing obstacles alike. Wrapping is
decided once, at measurement, against max-width (or the content’s natural
width); a later stretch widens the box, never the line breaks. Three errors
keep it honest (SPEC 21): nowrap text that cannot fit the cap, a
non-text child wider than the cap (only text wraps), and a width floor above it.
Exceptions: a text node sizes to its glyphs (no padding), widened by
letter-spacing and given line-spacing between \n lines; |icon| is a square
that grows with its [ ] text (a 32 floor) and needs a symbol; |line| / |poly| /
|image| / |path| require their geometry (points / src / path) and error
without it. |block| carries padding: 0, so a bare block sizes to its content
exactly.
Text is measured from real metrics. Width = Σ per-glyph advances at the
compile-resolved font-weight, read from the bundled metrics tables
(SPEC 6); no kerning or shaping (≈ 1 % of a line, the
documented tolerance — SPEC 24). Metrics follow the kind, not
the name: a mono font-family — a known-mono name, or any name containing
“mono” — measures on the mono table (exactly 0.6 em per glyph, at every
weight), every other family, the bundled default included, on the proportional
table. An unknown glyph
falls back to a fixed advance (wide for the CJK ranges). Vertical centring is
cap-height optical centring, from the same tables.
6. Paint, Stroke & Text
The visual vocabulary shared by every node. These are ordinary properties — the full list, with value shapes and defaults, is the Property Ledger; the colour system they draw on is SPEC 10. This section is the behaviour.
Paint
fill paints a body, color a label. fill is a closed shape’s interior (and,
on text, an alias for its fill); color sets text colour for a subtree and
cascades through the SVG via native currentColor — set it on a container to recolour
every descendant’s text that doesn’t override. opacity (0–1) fades a node whole.
fill, stroke, and gap-fill each accept a gradient as well as a flat colour
(SPEC 10).
Stroke
One stroke role paints a shape’s outline and a link’s wire alike — stroke the
colour, stroke-width the thickness (markers scale with it), stroke-style the dash
pattern (solid / dashed / dotted, plus the drafting center / phantom on
shapes and |line|s and wavy on links — SPEC 7). There is no parallel
link-* family: a .class carrying stroke dresses whichever wears it, node or link
(SPEC 9). A closed primitive’s default outline is --stroke at width 2; a
|group| softens to width 1.
Text
The text family — font-family, font-size, font-weight, font-style,
text-transform, text-decoration, letter-spacing, line-spacing, and color —
inherits: nearest ancestor wins, like CSS. Set it on a containing box (or the root)
and it cascades down, or on a string’s own block ("x" { font-weight: bold }) for
that one text node. Body text defaults to font-size 15, font-weight 500.
Chrome text scales with the body: a caption reads 12∕15 and a link label
11∕15 of the inherited font-size — 12 and 11 at the default — so one
font-size: scales the whole scene; an explicit font-size on either
(|caption| { }, |-| { }, a class, an own block) is absolute. (A drawing’s
annotation text stays the sheet convention, 12 — SPEC 15.1.)
Two bundled families (both SIL OFL 1.1) carry the metrics (SPEC 5):
Google Sans, the proportional default, and Google Sans Code, the mono one
declaration away (font-family: "Google Sans Code") — four static roman weights each.
A font-family override changes only the emitted name: measurement stays by
kind (mono vs proportional), so a runtime CSS restyle keeps the compiled layout
box. font-weight takes normal | medium | semibold | bold | 400 | 500 | 600 | 700 (normal = 400, bold = 700; arbitrary 100–900 is deferred —
SPEC 24); measurement reads the resolved weight (mono advances are
weight-invariant). How fonts leave the compiler — names, embedded, outlined — is
SPEC 18’s three output modes.
Line alignment rides the packing knob — there is no text-align. A text leaf’s
lines — wrapped (SPEC 5) or authored \n lines — align per its
nearest container box’s horizontal packing knob: justify in a row (so, by
default — SPEC 11), align in a column or grid context,
mapped start / center / end (stretch /
evenly / origin read as center). The knob reaches the lines even when the box
has no slack to move children; every box is a container, so the box holding the
text decides, and the default is center everywhere. Split intents wrap the text
in its own |block| { justify: … } — the table rule (SPEC 12)
generalised to every box, which is why there is no second text-align knob.
Two kinds of text property, split by whether they touch layout:
- Baked —
letter-spacing,line-spacing,font-size, andtext-transform— changes layout (the text box grows to fit the wider glyphs, the taller block, the capitals) and compiles into the glyph and line positions —text-transforminto the text itself, measured as it will draw — never emitted as a style (SPEC 1).letter-spacing/line-spacingdefault to 0, so text is unaffected until set. - Live CSS —
font-style,text-decoration,text-shadow— does not touch layout: it rides the class /<g>/.linirule and a host page can override it. Set any in the global block to style the whole scene.
For a global font-family / color, prefer the --lini-font-family /
--lini-text-color variables (or the --theme CLI flag, SPEC 20) for an embeddable diagram — they stay
live for a host page to re-theme, where a global property bakes its value into the
.lini rule (SPEC 10, SPEC 18).
7. Nodes
12 primitives. All accept position (SPEC 5) and paint (SPEC 6);
closed primitives also accept multiple, rotate, shadow. Text is not a primitive —
it is bare content (SPEC 3); the frameless |block| box
(SPEC 8) is what you reach for when text needs an id, a class, a link, or box
layout.
Dimensions use width / height, each defaulting to auto (content + padding,
border-box — SPEC 5). They are always bbox dimensions:
|oval| { width: 60; height: 40 } is an ellipse in a 60×40 box; equal dimensions (or an
empty |oval|) make a circle.
| Primitive | Required | Notes |
|---|---|---|
|block| | size (auto) | The base rectangle — frameless (no fill/stroke, radius: 0, padding: 0), like a div. It keeps stroke-width: 2 (invisible while stroke: none), so a styled block gets a sensible border. |box| frames + rounds it, |rect| frames it sharp (SPEC 8). |
|oval| | size (auto) | Bbox ellipse; equal width/height = circle. |
|hex| | size (auto) | Regular hex, flat top/bottom. |
|slant| | size (auto) | Parallelogram; top edge shifted tan(skew) × h. skew in degrees, (-89, 89), default 15. |
|cyl| | size (auto) | Cylinder; end ellipses ≈ h/10. |
|diamond| | size (auto) | Rhombus inscribed in the bbox. |
|poly| | points | ≥3 points, local (centre-origin) coords. Closed. |
|path| | path | Raw SVG path. Native top-left coords. |
|line| | points | 2+ points. Markers via marker*:. |
|icon| | symbol | A Phosphor icon — symbol: (or the label) names it; paints two-tone like a box (fill body, stroke line, counter-scaled stroke-width). A square that grows with its [ ] text (32 floor); |sign| is the larger preset. See Icons. |
|image| | src, width, height | A picture — src: a URL, a data URI, or a local path; local files embed (see Images); both dimensions required. fit maps it into the box — auto (default, letterbox), contain, cover, or stretch. |
|sketch| | draw | A pen that folds to a path — profiles drawn call by call, with named points and edges, mirroring, and view breaks (SPEC 15.3). Closed-primitive paint; bbox from the geometry. |
radius rounds a rectangle’s corners — |box| defaults to 8, |block| / |rect|
to 0. It is honoured on the rectangle (and on a multi-point |line|’s joins); radius
on the non-rect primitives (hex / diamond / slant / poly) is deferred (SPEC 24).
Visual modifiers (closed primitives)
| Property | Forms | Effect |
|---|---|---|
stroke-style | solid / dashed / dotted / center / phantom | Stroke pattern. Default solid. center (dash-dot) and phantom (dash-dot-dot) are the drafting line conventions — axes and alternate positions — valid on shapes and |line|s everywhere (SPEC 15.7); a link’s set stays solid / dashed / dotted / wavy (SPEC 9). wavy is link-only by design — a wire waves, an outline never does. |
multiple | N / dx dy | Draw one offset duplicate behind the node — the “several of these” reading. Scalar N = N -N. |
rotate | N degrees | Rotate around the bbox centre (SPEC 5). |
shadow | N / dx dy / dx dy blur / dx dy blur color | Drop shadow via SVG <filter>. Scalar N = offset N N, blur N; tint defaults to --lini-shadow-color. |
Markers (on |line| and links)
| Property | Effect |
|---|---|
marker: X | Both ends. |
marker-start: X | Start end (link source). |
marker-end: X | End end (link target). |
Values: none, arrow, dot, circle, diamond, datum (the filled drafting
triangle a drawing’s >- leader lowers to — SPEC 15.7), and the ER cardinality set —
crow (the “many” foot — many is its alias), one (a bar |), exactly-one (a double bar ‖),
zero-or-one, one-or-many, zero-or-many (a bar or ○ paired with the foot). The
compositional operators -+ / -< / -o+ / -+< / -o< / -++ are sugar over this
set (SPEC 9). circle is a larger dot — a filled point sized for
hovering or reading (on a chart line it marks a data point; SPEC 14). Markers scale
with stroke-width (a link’s wire and a shape’s outline alike; the size law is
SPEC 19’s); colour follows the stroke.
|line| is bare by default — write |line| { marker-end: arrow } for a one-shot
arrow. For links the operator picks markers (see SPEC 9). Source order wins:
marker: arrow; marker-end: dot → start arrow, end dot.
Icons
|icon| draws a Phosphor icon (MIT) as inline SVG
paths — themeable, reproducible, and renderer-agnostic (no icon font). The symbol
property names it — or, as the smart label, the string does (|icon| "heart" is
|icon| { symbol: heart }); everything else paints like a box:
|icon| "bell" // symbol via the label
|icon| { symbol: warning-circle; stroke: --amber-ink } // the longhand
|icon| "heart" { fill: --rose-wash; stroke: --rose-ink }
|icon#tag| "bell" [ "3" ] // symbol bell, "3" rides as text
Setting the symbol twice — a label and { symbol: … } — is an error; pick one.
Phosphor icons are two-tone (a soft fill behind a line), so an icon wears Lini’s
paint roles like any node: fill the body (default the soft grey --icon-fill),
stroke the line (default --stroke, matching borders and wires),
stroke-width its weight (default 2). A single-tone line icon is fill: none.
stroke-width is counter-scaled: an icon is authored on a 256-unit grid and fit
to its box, and the stroke is divided by that scale (baked at compile time), so its
line weight holds as the icon resizes.
An icon is a square that grows uniformly with its [ ] text (and padding): the
side is a 32 floor (icon-size) over the text + padding on either axis — an empty
icon is 32×32; a longer label scales the whole icon up, symbol and all, never
distorting it. For a larger stand-alone icon, reach for |sign| (SPEC 8).
fit controls how the symbol fills that box. auto (default) keeps Phosphor’s
authored framing — each glyph’s built-in 256-grid margin — so a row of mixed icons
reads at an even weight; contain scales the glyph’s own bounds up to meet the
box (|sign|’s default); cover scales until the box is covered (may overflow);
stretch fits both axes (may distort). The counter-scaled stroke-width follows
the resulting scale, so line weight is constant whichever fit you choose.
A missing symbol errors like |poly| without points; an unknown one suggests the
nearest name. Only the icons a diagram uses are embedded (a default-on icons feature,
SPEC 24).
Images
|image|’s src: takes an HTTP(S) URL, a data: URI, or a local path,
resolved against the source .lini file’s directory. A local file’s bytes are read
once, at resolve — a missing or unreadable path errors at the src: span — and
embedded in the output (SPEC 18): SVG as a nested, id-isolated
<svg>; raster (PNG / JPEG / GIF / WebP) as a base64 data URI. Embedding is the one
behaviour for a path (there is no opt-out — a self-contained SVG is the output
contract) and is deterministic from the bytes: the same file and assets give
byte-identical output on every run. The compiler never touches the network — URLs and
authored data URIs pass through untouched, so a URL is the authored non-embedded
form. Under lini serve, assets resolve inside the served root only
(SPEC 20).
8. Templates
Built-in types — each a bundle over a primitive base, named because the pattern is
common. Every rectangular template is a bundle over |block|; the non-rect
primitives (SPEC 7) stand on their own. A template’s defaults are the low tier of
the cascade (SPEC 4) — every value here is overridable.
| Template | Base | Defaults | For |
|---|---|---|---|
|box| | |block| | fill: --fill; stroke: --stroke; stroke-width: 2; radius: 8; padding: 20 | The default node — a rounded, framed card. |
|rect| | |box| | radius: 0 | A sharp-cornered box. |
|group| | |block| | stroke: --group-stroke; stroke-style: dashed; stroke-width: 1; fill: --group-fill; radius: 8; padding: 20 | Dashed frame for a caption + children. |
|caption| | |block| | pin: top left; translate: 0 -20; color: --caption-color; font-size: 12∕15 of inherited ([SPEC 6](06-paint-stroke-text.md#6-paint-stroke--text)); font-weight: --caption-font-weight | A title, pinned just above the group’s top-left corner. |
|footnote| | |caption| | pin: bottom; translate: 0 19; color: --footer-color | A caption flipped to a shape’s bottom edge — a centred, muted footnote. |
|sheet-caption| | |caption| | translate: 10 10; font-size: 16∕15 of inherited; font-weight: semibold | A block title seated inside its frame’s top-left corner — what a schematic scope’s label lowers to (SPEC 16.6). |
|badge| | |block| | pin: top right; translate: 6 -6; radius: 8; padding: 2 6; shadow: 2 3 3; fill: --accent; color: --accent-text; font-size: 11; font-weight: normal | Corner pill — nudged out over the top-right corner, grows nothing. |
|row| | |block| | direction: row | Frameless wrapper — children in a row. |
|column| | |block| | direction: column | Frameless wrapper — children in a column. |
|grid| | |block| | layout: grid | Frameless grid (needs columns). |
|sign| | |icon| | width: 64; height: 64; padding: 4; stroke-width: 2; fit: contain | A larger icon as a stand-alone node, with room for a short label; fit: contain fills the box (unlike a bare |icon|). |
|table| | |group| | layout: grid; align: stretch; justify: stretch; gap: 1; gap-fill: --stroke; padding: 0; fill: none; stroke: --stroke; stroke-width: 2; stroke-style: solid; font-size: 14; font-weight: normal; scale: 1 | Ruled grid (see below). |
|cell| | |block| | padding: 4 8 | A table cell — a frameless |block| carrying the text-to-gutter inset (the mechanism: Tables, below). |
|header| | |cell| | fill: --header-fill; font-weight: semibold | A header cell — a filled, semibold band (a |table|’s first row; an |entity|’s title spans them). |
|footer| | |cell| | color: --footer-color | A footer cell — muted text; opt-in on the last row. |
|entity| | |table| | columns: auto, auto | An ER / database entity — a titled field list, rows left-aligned (see below). |
|topic| | |block| | fill: --fill; stroke: --stroke; stroke-width: 2; radius: 8; padding: 8 14 | A tree’s structural node — topic nesting is the hierarchy, anything else in its [ ] is the topic’s own content (SPEC 12); custom structural types derive from it (|person::topic|). Tree-only. |
|mindmap| | |topic| | layout: tree; direction: bilateral; routing: natural — plus the palette walk, the depth ramp, and max-width: 160 on topics (see below) | A mindmap — the node is the visible root topic, its [ ] topics the first-level branches (SPEC 12). |
|note| | |block| | fill: --fill; stroke: --stroke; padding: 20; scale: 1 | A note — the folded-corner callout card, one type in every layout (see below). |
|balloon| | |oval| | width: 16; fill: --fill; stroke: --stroke; font-size: 11; scale: 1 | An item balloon — the numbered circle an assembly leaders to a part (SPEC 15.8). |
|stack| | |block| | layout: stack; padding: 0 | The datum container (SPEC 12) — children place their origin on it instead of flowing. |
|drawing| | |block| | layout: drawing; padding: 0 | An engineering drawing — geometry on a datum, measured annotations; scale: is its drafting ratio, default 1 (SPEC 15). |
|hole| | |oval| | fill: --bg; stroke: --stroke-dark — width: required, the diameter | A round hole — punches by paint order, draws its own centre marks (SPEC 15.4). |
|centerline| | |line| | stroke-style: center; stroke: --stroke-light; stroke-width: 1; fill: none — needs points: | The dash-dot axis / symmetry line (SPEC 15.7). |
|pitch-circle| | |oval| | stroke-style: center; stroke: --stroke-light; stroke-width: 1; fill: none — width: required, the diameter | The dash-dot bolt circle; round, so a (o) reads its PCD (SPEC 15.7). |
|breakline| | |line| | stroke: --stroke-light; stroke-width: 1; fill: none — needs points: | A break cut’s edge — the thin jogged line a break: generates (SPEC 15.3); manual use is free. |
|halo| | |line| | generated chrome — halo-margin each side | An annotation line’s crossing knockout over geometry (SPEC 15.7); |halo| { … } restyles or removes them scope-wide. |
|threadline| | |line| | stroke: --stroke-light; stroke-width: 1; fill: none — generated chrome | A thread’s ISO 6410 thin line — the minor/major run and the ¾ arc a thread: generates (SPEC 15.3, SPEC 15.4). |
|hidden| | |sketch| | stroke-style: dashed; stroke: --stroke-dark; stroke-width: 1; fill: none — needs draw: | Hidden edges — interior geometry on its own dashed child, per the one-node-one-stroke-style law (SPEC 15.7). |
|shoulder| | |line| | stroke: --stroke-dark; stroke-width: 2; fill: none — needs points: | A turned part’s shoulder line — the geometry-weight edge a revolve: generates at every sharp diameter change (SPEC 15.3); manual use is free. |
|plane| | |line| | stroke-style: center; stroke: --stroke-light; stroke-width: 1; fill: none | The section-plane line on the source view — its label the section letter; at: stations it, facing: turns its arrows; a |drawing| { of: } sections it (SPEC 15.8). |
|magnifier| | |oval| | stroke: --stroke-light; stroke-width: 1; fill: none — width: required, the region diameter | The detail marker — rings a region on the source view, its label the detail letter at the rim; a |drawing| { of: } details it (SPEC 15.8). |
|projection| | |line| | stroke: --stroke-light; stroke-width: 1; fill: none — needs points: | A projection construction line — the straight thin line a sheet’s cross-view link generates (SPEC 15.8); manual use is free. |
|surface-finish| | |block| | symbol: basic; stroke: --stroke-dark; stroke-width: 1; fill: none; font-size: 12; scale: 1 | The ISO 1302 surface-texture symbol — its label the textual indication, symbol: the vee variant; drawing-scope (SPEC 15.9). |
|feature-control| | |block| | stroke: --stroke-dark; stroke-width: 1; fill: --bg; font-size: 12; scale: 1 | The GD&T frame — characteristic, tolerance, datums in ruled compartments; rows via |control|; drawing-scope (SPEC 15.9). |
|control| | |block| | — | One frame row — its label the characteristic; a |feature-control| child only (SPEC 15.9). |
|datum| | |block| | stroke: --stroke-dark; stroke-width: 1; fill: --bg; font-size: 12; scale: 1 | The framed datum letter as a node — its label the letter, an identity like >-’s; drawing-scope (SPEC 15.7, SPEC 15.9). |
|page| | |block| | layout: flow; fill: --bg — sheet: a4 unless sized; direction by orientation | An ISO 5457 drawing sheet — mm dimensions via sheet:; px per mm from the root density:; frame, zones, and centring marks as generated chrome (SPEC 15.8). |
|title-block| | |table| | font-size: 14; font-weight: semibold; stroke-width: 1 | The ISO 7200 title block — a table the |page| seats flush inside its frame’s bottom-right corner. Field properties (title, drawing-number, revision, date, sheet-number, author, …) build the standard grid, absent fields collapsing; its smart label is the title field; plain cells stay a fully custom block (SPEC 15.8). |
|frame| | |rect| | fill: none; stroke: --stroke; stroke-width: 2 | A sheet’s frame — the thick border a |page| generates at the ISO margins (SPEC 15.8). |
|field| | |block| | font-size: 12; font-weight: normal; color: --footer-color | A title-block field’s caption, over its value — quieter in size, weight, and tone (SPEC 15.8). |
|zone| | |block| | font-size: 11; color: --stroke-light | A zone reference label (1, 2… / A, B…) a |page| generates in the margin band (SPEC 15.8). |
|tick| | |line| | stroke: --stroke; stroke-width: 1; fill: none — needs points: | A zone divider / centring mark a |page| generates (SPEC 15.8). |
|schematic| | |block| | layout: schematic | A circuit sheet’s scope (SPEC 16). |
|component| | |block| | fill: --component-fill; stroke: --component-stroke; stroke-width: 1.5; radius: 0; padding: 8; prefix: "U" | The generic pin-bearing part — IC, module, relay; ref prefix U (SPEC 16.2). |
|pin| | |block| | name inside, number: outside, stub outward — no side: default (the bilateral split, SPEC 16.2) | A component terminal — the wire lands on its stub tip. |
|space| | |block| | width: 20; height: 20 — one rail slot, span: N for N | An empty slot between a component’s pins (SPEC 16.2). |
|label| | |block| | shape: plain; font-size: 11; color: --label-ink | The net tag — text, a symbol (gnd, power, …), or both; its own terminal (SPEC 16.4). |
|junction| | |oval| | fill: --wire; stroke: none — generated chrome | The connection dot where ≥ 3 wire ends meet (SPEC 16.5). |
|J| | |component| | prefix: "J" — pins nameless, pins: N generates them side: left (one column) | The connector (SPEC 16.2). |
|opamp| | |component| | prefix: "U" — pins out inp inn; power pins hidden | The amplifier triangle (SPEC 16.2). |
the discretes — |R| |C| |L| |D| |LED| |Q| |Y| |F| |FB| |SW| |BT| |V| |I| |M| |BZ| |TP| | |block| | symbol-bodied, generated pins, symbol: variants | The one/two/three-terminal parts; the type is the ref family (SPEC 16.3). |
|gnd| / |nc| | |label| | symbol: gnd / symbol: nc | Built-in ground / no-connect defines (SPEC 16.4). |
|floorplan| | |drawing| | layout: floorplan | An architectural floor plan — the drawing engine in a dialect, so |drawing|-scoped rules dress it too (SPEC 15.11). |
|wall| | |sketch| | fill: --stroke-dark; stroke: none — draw: traces the centreline; thickness: inherited (200 mm) | A wall run — offset to a solid (poché) outline at lowering; openings ride its [ ] (SPEC 15.11). |
|partition| | |wall| | thickness: 100 (mm — the true-size law, SPEC 15.11) | The thinner interior wall — a define, nothing more. |
|door| | |block| | on: required; width: 900 (mm); hinge: start; swing: left | A wall opening — gap + leaf + swing arc; symbol: single / double / sliding (SPEC 15.11). |
|window| | |block| | on: required; width: 1200 (mm) | A glazed wall opening — gap + sill lines (SPEC 15.11). |
the opening / stair chrome — |door-leaf| |door-swing| |window-sill| |stair-tread| |stair-arrow| | |line| | stroke: --stroke-light; stroke-width: 1; fill: none — generated chrome | What an opening and a flight draw: the leaf (a slider’s panels too), its quarter swing arc, a window’s sill pair, the treads and the up arrow; |door-swing| { … } restyles or removes them scope-wide (SPEC 15.11). |
the fixtures — |bed| |sofa| |dining| |bath| |appliance| |stairs| | |block| | stroke: --stroke-dark; stroke-width: 1; fill: --bg — symbol-bodied, true-size mm defaults; symbol: variants (|stairs| takes none — steps: N required) | The furniture set — thin outline, masking what it overlaps (SPEC 15.11). |
The bare |block| is the base everything rectangular builds on — frameless, yet a real
box (id, class, children, wirable, positionable): what you reach for to wrap text that
needs box behaviour.
Captions. Both caption templates are out-of-flow overlays — they never push
the content, their place fixed by the template, not by where they sit among the
children, so a row-laid group carries its title just the same. A group’s
label is its caption (SPEC 3), so |group#panel| "Settings" [ … ]
and |group#panel| [ |caption| "Settings" … ] are equal;
|caption| { font-size: 16 } styles every caption without touching body text.
Notes. A |note| is the callout card — a filled block with a folded top-right
corner. It is one type in every layout: in a sequence it binds to lifelines with
place: (SPEC 13); in a drawing it places at the
datum, usually wired by a leader (SPEC 15.7); in
flow / grid it is an ordinary padded card. Built-in scoped rules — |sequence| |note|
and |drawing| |note|, each { padding: 6 10; font-size: 13 } — keep it compact where
convention expects; override them like any rule.
Topics & mindmaps. A |topic| is the tree’s structural node
(SPEC 12), a compact card whose label is its centred
text. |mindmap| is the visible root topic owning the scene — layout: tree; direction: bilateral; routing: natural — plus three deterministic garnishes,
each lowered at desugar as ordinary generated rules (visible in lini desugar, overridable like any rule): the palette walk — each first-level
branch takes the next hue (SPEC 10.2) in
declaration order, red and grey skipped, and tints its subtree at the tiers
(wash fill, deep stroke and branch wires, ink text; the root stays
neutral, explicit paint wins, cross-links stay neutral, dark mode free); the
depth ramp — .lini-level-N rules size the tiers (root largest, level 1
medium, deeper small); and topic wrap — max-width: 160, so a long label
wraps into a card instead of stretching an arm. A plain layout: tree
carries none of these — org charts read monochrome.
Tables. A |table| is pure sugar over the bundle above — its 1px
gap-fill gutters paint as hairline rules (SPEC 11).
Each body cell wraps in a |cell|; |header| / |footer| build on it, so
every cell — but not the caption, a plain |block| — carries the inset. Style
all cells with |cell| { … }, or per table with |table| |cell| { … }. The
table’s align: stretch; justify: stretch makes every cell fill its track
— backgrounds fill and text has room. A table’s label is its caption.
Column alignment. align (↔) / justify (↕) on the table read per column
(SPEC 12) and align the cells’ text: since the cells already fill, the
table’s own align/justify are carried onto each cell — a start/end column’s cells
wear a .lini-align-* / .lini-justify-* class — and a filled cell places its text at
that edge (center is the default). So align: start, center, end reads three columns
left / centre / right, header band and body alike.
A table’s first row becomes its header — each cell wrapped as a |header|, a filled
semibold band; |table| |header| { font-weight: normal; fill: none } reverts it. A footer
is opt-in: wrap a last-row cell in |footer|. Every cell is a box now — header/footer
carry a fill; a body cell is a frameless |block| wrapping its text, so the padding rule
and the column’s alignment reach it (SPEC 18).
|table#basket| {
columns: 80, 140, 80;
} [
"Fruit" "Quantity" "Notes" // the header row — filled + bold
"Apple" "12" "fresh"
"Mango" "3" "ripe"
]
fmt knows the column count and pads the cells into aligned columns, so the flat form
reads like the table it is. A cell that must be placed or linked is a box child
(|cell| "X" for a padded cell, or |box| { cell: 2 1; … }); a cell that just needs a
colour or weight can take its own style block ("Apple" { color: --red-ink }).
Entities. An |entity| is sugar over |table| (two auto columns) for an ER /
database card: its label is its title — a |header| spanning every column, centred
over left-aligned "field" "type" rows (an entity’s field rows read left by default; the
title keeps its centred, full-span band). Add a column for a key gutter —
{ columns: auto, auto, auto } gives "PK"/"FK" "field" "type". In an entity (not a plain
table) a |header| / |footer| cell spans the full width.
|entity#users| "Users" [ "id" "int" "name" "varchar" ]
Relationships are ordinary links with the ER cardinality operators (SPEC 9):
users -< orders is one-to-many, a >-< b many-to-many, landing on the entity
edge. To anchor a wire to one field, give that cell an id
(|cell#user_id| "user_id") and link the path (orders.user_id -< users.id):
the card’s cells tile, so the wire leaves the card at that field’s row
rather than crossing the cells (ROUTING).
Keys are plain content ("id" { font-weight: bold }); an entity adds no grammar.
Extend any template: |panel::group| { stroke: --accent }. Common nodes need no
template:
| For | Write |
|---|---|
| Circle | |oval| { width: 40 } |
| Database | |cyl| |
| Arrow | |line| { marker-end: arrow; points: 0 0, 50 0 } |
9. Links
A link connects scene-node ids with an operator (a -> b). Like every node it has a
{ } style and a [ ] of content — its content is its labels (text),
placed along the route by along:. It is never written as a |link| instance; the
operator draws it.
A link is styled like a node: its type is |-| — a line in the identity capsule,
the one selector that matches every link — so stroke is its wire and color /
font-* its labels, the ordinary vocabulary (SPEC 6) with no
parallel family. Only clearance and routing stay scene config
(Styling).
Operators
A link op is [start_marker?][line][end_marker?], no spaces:
| Part | Tokens |
|---|---|
| Line | - solid · -- dashed · --- dotted · ~ wavy |
| Start markers | < arrow · > crow · * dot · <> diamond · +/o ER cardinality (below) |
| End markers | > arrow · < crow · * dot · <> diamond · +/o ER cardinality (below) |
The same marker glyph differs by position (< is arrow at the start, crow at the
end).
| Op | Markers / Line |
|---|---|
-> <- <-> | arrow combinations, solid |
-* *- *-* | dot combinations |
-<> <>-<> | diamond |
-< -+ -o< -+< -o+ -++ >-< | ER cardinality (crow’s-foot, below) |
--> ---> ~> | dashed / dotted / wavy |
- -- --- ~ | no markers (each line style) |
An operator with no marker glyphs leaves both ends bare. Explicit marker: /
marker-start: / marker-end: override the operator (source order wins). The
operator’s line part sets the link’s stroke-style (-- ⇒ dashed, --- ⇒ dotted,
~ ⇒ wavy); an explicit stroke-style: overrides it.
ER cardinality — a crow’s-foot marker, composed. A cardinality marker reads
[min][max]: the min ring o (zero) or bar + (one) hugs the line, the max
bar + (one) or crow (many — < at the end, > at the start) sits outermost. Either
end takes one; the two sides mirror — a +-< b is one-to-many, a >o-o< b zero-or-many
both ways. The six relations, shown end-side:
| Op | Relation |
|---|---|
-+ | one |
-< | many |
-o+ | zero-or-one |
-+< | one-or-many |
-o< | zero-or-many |
-++ | exactly one |
A lone -o (no max) errors; the hollow ring exists only inside the ER
cardinality glyphs and has no standalone endpoint form. The ops are sugar over the marker: set (one,
exactly-one, zero-or-one, one-or-many, zero-or-many, crow —
SPEC 7); marker*: overrides.
Syntax
endpoints op endpoints [op endpoints …] [ "label" ] [ .class… ] [ { style } ] [ [ labels ] ]
The tail is the node tail ("label" .class { style } [ … ]); only the head differs
— endpoints + operators, versus bars — and a link’s [ ] holds only labels (text),
where a node’s holds children (a drawing’s dimensions and leaders alone may also
carry annotation nodes there — SPEC 15.9).
endpoints is one or more endpoints joined by &:
a -> b // 1 link
a -> b -> c // chain: 2 links
a -> b & c // fan-out: a→b, a→c
a & b -> c // fan-in
a & b -> c & d // cartesian: 4 links
a -> b -> c & d // chain + fan
Each hop carries its own wire operator; mixing operator kinds — a wire op with
a measure or mate — in one chain is a parse error. On a chain or fan, the label,
class, and { } apply to every link the statement expands to.
A chain marks every hop. a -> b -> c is exactly a -> b; b -> c — desugar
expands the chain (SPEC 19), so each hop carries the
operator’s full markers and lini desugar shows the two links. A bare first hop
is spelled with the bare line op: a - b -> c. (Fan-out & is not sugar — its
shared trunk is routing geometry, ROUTING.md.) A schematic
scope is the one carve-out (SPEC 16.5).
Styling
The vocabulary is SPEC 6’s, at the ordinary defaults
(SPEC 17): stroke / stroke-width /
stroke-style dress the wire (the style usually set by the operator, above),
color and the font-* family its labels (Labels).
|-| { … } styles every link; a descendant (#g |-|, |table| |-|) or a worn class
scopes it, exactly as |box| / #g |box| / .hot scope a node; a link’s own { }
overrides — the same cascade a node walks (SPEC 4):
{
|-| { stroke: #888; stroke-width: 1.5; font-size: 12 } // every link
#g |-| { stroke: --blue } // links written in #g
.flow { stroke: --teal } // a worn class — nodes or links
clearance: 12; routing: orthogonal // scene config, cascades to links
}
a -> b "hi" .flow { stroke: red; stroke-style: dashed } // one link overrides
clearance and routing are scene config — geometry, not paint — set on a
container’s { }, cascading to that scope’s links, nearest winning; the router then inflates every
node’s keep-out by the maximum clearance any link carries
(ROUTING.md). marker* come from the operator and
override per link.
Labels
A link’s label is text, placed along the route by along: — the link’s track
rule, exactly as columns: is a grid’s. One label trails the head (a -> b "watches"); two or more, or a styled one, ride the [ ]:
| Property | Notes |
|---|---|
along | A list of 0..1 fractions along the whole drawn route, one per label (along: 0.2, 0.5, 0.8). Omitted → auto-distribute across the hops, so one label avoids junctions and several spread out. |
a -> b "watches" // the common case — one label, auto-placed
a -> b "watches" .loud { stroke: red } // + a class and wire colour
a -> b { along: 0.3, 0.7 } [ "near a" "near b" ] // two labels
a -> b [ "watches" { translate: 0 -6 } ] // a styled / nudged label
Each label is an ordinary styleable text leaf; the head label takes no style
(SPEC 3) — a styled label rides the [ ], exactly as a
node’s does. Keep one link’s labels in one [ ] — a head label and a
[ ] of labels on the same link warns (SPEC 21). A label is an obstacle to nothing, and may slide along the link to keep
clear of nodes and other labels; the link never moves for it. A label cuts its
wire: the route is masked open behind the label’s box — the same luminance
knockout a drawing’s |halo| puts where annotation linework crosses geometry
(SPEC 15.7), so the break holds over any
background and in either theme — and it cuts every wire the box reaches, not
only the labelled one. A schematic sheet draws the other convention, standing the
net name beside the trace (SPEC 16.5). Link labels ride the
chrome size (SPEC 6) at font-weight: normal; a link’s text props
cascade to its labels
(|-| { font-size: 14; color: --blue } restyles every link’s labels at once,
absolutely).
Endpoints & scope
endpoint = ( ident | ident_bars ) { "." ident } [ ":" side ]
side = top | bottom | left | right
A path walks with . into children; a final :side forces a side. An
endpoint may open with an identity capsule instead of an id — see
Capsule endpoints below. Every link
resolves in a scope — the scene root for top-level links, the container’s body for
links written inside one. The first segment names a node in the scope, each further
segment a child of the previous. There is no search. A single bare id not in the
scope auto-creates a box there (Implicit nodes); a multi-segment
path that does not resolve is an error, and the error suggests full paths of
same-named nodes —
link endpoint 'kitchen.bowl' not found at scene root; did you mean 'kitchen.counter.bowl'?
| Endpoint (root link) | Resolves to |
|---|---|
cat | root node cat |
kitchen.counter.bowl | exactly that path |
kitchen.counter.bowl:left | the same node, left side forced |
Capsule endpoints
An endpoint position may hold an identity capsule — bars, exactly as a
declaration writes them: |type| or |type#id|. A capsule declares and
links in one statement: desugar hoists it to an ordinary declaration at
the statement’s position in its scope and the link references it
(SPEC 19), so it is the typed form of
declaration-at-first-use (SPEC 3):
cat -> |cyl#db| // declare db (empty, per SPEC 3), link to it
cat -> |cyl#db| "watches" { … } // the tail is the LINK's, as always
a -> |box| -> c // anonymous mid-chain — a minted internal id
a & b -> |gnd| // a fan into ONE instance
Three existing laws govern it, none new:
- A statement’s tail belongs to its head (SPEC 3) — a capsule takes no label, class, style, or children; everything after it is the link’s.
- Identity travels, dress doesn’t (SPEC 1) — the id inside the bars comes along; classes never sat inside bars.
- Declared nodes are empty unless labelled (SPEC 3) — a
define supplies intrinsic content (
|vm::label| { symbol: power } [ "VM" ]).
The capsule composes with the rest of the endpoint’s anatomy (.path,
.index, :side) — though an inline capsule has no authored pins, so a
pin path on one (|component#U9|.p4) is an error; anonymous capsules mint
reserved internal ids (lini-cap-N); an id’d capsule declared twice is the
ordinary duplicate-id error. At statement head, a capsule followed by a link
operator opens a link (SPEC 1); followed by anything else
it is the node declaration it always was. A drawing scope rejects
capsules — a drawing never invents an endpoint (SPEC 15); a
sequence accepts them (a typed participant).
Bodies are sealed: a body link connects nodes of its own subtree only.
Cross-container links are written at the lowest level where both ends are visible —
usually the root. Without a side the router picks edges by geometry; with a :side,
that edge is forced.
An anonymous container opens no scope: it is scope-transparent — its
children belong to its parent’s scope (ids stay unique across it), a dot-path
never names it, and its own [ ] links resolve in the parent’s scope. Name a
container to give its children a dot-path of their own. A sequence frame is
transparent the same way (SPEC 13). Scope-transparency is
about names, not geometry: the router sees the container itself, so links
route inside an anonymous group exactly as inside a named one; its scene
config (clearance:, routing:) cascades onto the links written in it
(ROUTING.md Model step 1); and a layout-owning container
realises the statements written in it whether or not it is named — an
anonymous |drawing| draws its own dimensions, an anonymous |sequence| lays
its own messages on the time axis. The wiring strategy follows the container
that wrote the statement (SPEC 11, seam 2), never the
dot-path its endpoints resolve against.
Internal links in a body
A container’s (or define’s) [ ] may link its own children — children and links read in
source order, so a wire usually trails the boxes it joins but may also sit among them
(a layout: sequence (SPEC 13) relies on this — its frames interleave with its
messages). In a define, ids are local and materialize per instance — the same sealed-body
rule. From outside, the dot-path navigates in:
{
|room::group| {
gap: 10;
} [
|box#inlet| "Inlet"
|box#outlet| "Outlet"
inlet -> outlet "flows"
]
}
|room#garden| "Garden"
|room#kitchen| "Kitchen"
garden.outlet -> kitchen.inlet "carries"
Routing
routing selects the strategy for a scope and cascades like clearance:
orthogonal (the default) routes horizontal/vertical runs through the free space
between nodes, corners rounded; natural fits direct smooth curves —
tangent-normal at both ends, bending gently around what they would hit, free to
cross (ROUTING.md); straight draws each link as one
segment between the bodies, trimmed to their boundaries — it avoids nothing and
reports nothing. routing pairs with
layout — layout places the nodes, routing wires them — so a group can route
its internals one way while the root routes another; which subsystem realises a
scope’s links is the scope’s wiring strategy (SPEC 11).
The full routing contract — clearance, spacing, crossings, fan-out, self-loops —
lives in ROUTING.md, the source of truth for routing.
10. Colour, Variables & Expressions
CSS variables theme the visual layer — colours and the font family. Everything that affects layout — sizes, gaps, padding, and font size — is a baked constant, so a standalone SVG never depends on host CSS. This section also holds the expression engine (10.7), the one place operators appear.
10.1 Visual variables (live, themeable)
Each colour is a light-dark(LIGHT, DARK) value, so one SVG carries both modes:
--lini-bg light-dark(white, #1b1b1f) the scene background
--lini-fg light-dark(black, #e8e8ea)
--lini-fill light-dark(white, #26262b)
--lini-stroke light-dark(#444, #9aa0a6)
--lini-stroke-dark light-dark(black, white) the primary drafting tone — pen geometry, dimension/leader linework, and their heads read full black on white (the ISO print look)
--lini-stroke-light light-dark(#0000008b, #ffffffa3) the secondary line tone — drafting's thin support lines (centerlines, break lines, extension lines): full black/white at reduced alpha, so a support line crossing dark geometry blends toward it instead of greying it
--lini-accent light-dark(#0a84ff, #4aa3ff)
--lini-accent-text white text on an accent fill (e.g. a badge)
--lini-muted light-dark(#888, #9aa0a6)
--lini-danger light-dark(crimson, #ff6b6b)
--lini-warn light-dark(orange, #ffb454)
--lini-stray light-dark(crimson, #ff6b6b) the stray-link fallback (ROUTING.md, Impossible layouts)
--lini-group-stroke light-dark(rgba(0,0,0,.4), rgba(255,255,255,.4))
--lini-group-fill light-dark(rgba(0,0,0,.03), rgba(255,255,255,.05))
--lini-header-fill light-dark(rgba(0,0,0,.06), rgba(255,255,255,.08)) the table / entity header band
--lini-icon-fill light-dark(rgba(0,0,0,.16), rgba(255,255,255,.18)) the soft body behind a duotone icon
--lini-caption-color light-dark(rgba(0,0,0,.5), rgba(255,255,255,.55))
--lini-footer-color light-dark(rgba(0,0,0,.5), rgba(255,255,255,.55))
--lini-grid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.14)) the chart gridline tint
--lini-tip-bg light-dark(#333, #e8e8ea) the chart tooltip card's surface ([SPEC 14.8](#148-tooltips))
--lini-tip-fg light-dark(white, #1a1a1f) …and its text
--lini-font-family "Google Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif
--lini-font-weight 500
--lini-caption-font-weight 400
--lini-link-font-weight 400
--lini-text-color var(--lini-fg)
--lini-shadow-color light-dark(rgba(0,0,0,.2), rgba(0,0,0,.5))
--lini-wire light-dark(#0a7a2f, #4cc472) the schematic wire
--lini-component-fill light-dark(#fdf6d8, #3a3626) a part's body
--lini-component-stroke light-dark(#8a1c1c, #d98f8f) a part's outline
--lini-label-ink light-dark(#0e6a6a, #57c4c4) the net tag
--lini-pin-number light-dark(#00000073, #ffffff80)
--lini-sheet light-dark(#faf6ea, #22201b) the schematic scene wash
--lini-bg is the paper tone — what a root fill: --bg, a |page| sheet, and
a punched |hole| paint with. It is not painted unasked: a figure carries a
background only when the scene sets one (SPEC 18). The default stack leads with the bundled
proportional Google Sans (SPEC 6); its advances are
what the proportional metrics table measures, so a diagram measures identically
in every output mode (SPEC 18).
Dark/light is automatic. The compiler emits color-scheme: light dark on .lini,
so light-dark() follows the viewer’s OS (prefers-color-scheme) — no script, no
@media. A data-theme="dark" / "light" on the SVG or any ancestor forces a mode
(it flips color-scheme, and its higher specificity beats the OS). All defaults sit in
@layer lini.defaults, so unlayered host CSS still wins with no !important.
--static freezes the light arm into literals for renderers without light-dark()
(10.6).
10.2 The colour palette
Beyond the role variables, Lini ships a named-hue palette — pretty by default,
themeable, and dark/light-aware like everything else. Eleven hues, each a
light-dark() pair:
red rose orange amber lime green teal sky blue purple gray
Every hue carries five tiers, named for the job they do — not their lightness, which would invert in dark mode:
| Tier | Example | Job |
|---|---|---|
| wash | --teal-wash | palest — card and section backgrounds (a faint tint; a deep, muted surface in dark mode) |
| soft | --teal-soft | a gentle, lighter pastel fill |
| base | --teal | the everyday pastel — the bare name is the easy path |
| deep | --teal-deep | the strong tone — borders and strokes |
| ink | --teal-ink | deepest and most saturated — text and emphasis (the high-contrast tone in dark mode) |
The job-names hold across the dark flip — --teal-wash is always the faint
surface, --teal-ink always the high-contrast detail:
{ |card::box| { fill: --teal-wash; stroke: --teal-ink } } // a pretty card, one line
|box#n| { fill: --amber-soft }
The tiers are generated from one OKLCH seed per hue, so the ramp is perceptually
even and the eleven read as a family; the same space is open directly —
fill: oklch(0.7, 0.14, 200) (SPEC 2). Aliases cover muscle
memory: --yellow → --amber, --pink → --rose, --indigo → --purple,
--cyan → --teal. red stays clear for danger; rose is the decorating pink,
green an emerald, lime the lemony one.
The palette is tree-shaken — only referenced variables are emitted (SPEC 18).
10.3 Gradients
fill, stroke (a shape’s outline or a link’s wire), and gap-fill accept a gradient in place of a flat colour. Stops are
ordinary colours — palette --names flip dark/light and bake, a raw #hex is a fixed
literal.
| Form | Result |
|---|---|
gradient(--rose, --sky) | two stops, auto-angled 135° — any two hues blend cleanly |
gradient(--rose, --amber, --sky) | three or more evenly-spaced stops |
linear-gradient(135, --rose, --sky) | an explicit angle in degrees — the control gate |
radial-gradient(--rose, --sky) | a radial blend from the centre out |
|box#hero| { fill: gradient(--blue, --purple) } // a single-family sheen
|badge#tag| { fill: gradient(--rose, --amber, --sky) } // a three-colour pop
Each distinct gradient is emitted once as a <linearGradient> / <radialGradient> in
<defs> and referenced by url(#…) — deduplicated and shared like the drop-shadow
<filter>s (SPEC 18). objectBoundingBox units fit one definition to
any node at any size. The stops being palette vars, a gradient themes, flips, and bakes
like any other paint; gradient-on-text is deferred (SPEC 24).
Hatches. hatch() is a paint function beside gradient(), valid on fill
only — the drafting section-line texture, usable in any layout:
| Form | Result |
|---|---|
hatch(45) | section lines at 45°, pitch 6 |
hatch(45, 6) | explicit pitch (sheet-space px — hatch never scales, SPEC 15.1) |
hatch(45, 6, --gray-deep) | explicit line colour (default --stroke) |
hatch(45 -45, 6) | a space-group of angles — cross-hatch |
Angles use the drawing bearing (0 = up, clockwise — SPEC 15.3).
Each distinct hatch emits one <pattern> in <defs>, deduplicated like gradients; the
colour is an ordinary paint, so hatching themes, flips dark/light, and bakes. Hatch
line width is fixed (0.75) — a texture, not a stroke. hatch() on stroke is an
error — a stroke takes a colour or gradient.
10.4 --name references
--name is the visual-variable namespace, and only that. --name: value;
declares one (a built-in --lini-* name keeps its meaning; a new name is yours), and
--name in a value references it, emitting live var(--lini-name):
{
--brand: #ff6600;
}
|box#cat| { fill: --brand }
Alias a host var from CSS: .lini { --lini-accent: var(--my-brand-blue); }.
Layout values — sizes, gaps, padding, font-size, clearance — are not --name
variables: they bake (a runtime var() can’t be measured at compile time). Set them
with a literal, a rule (gap: 30;, |box| { radius: 4 }), or a (…) expression /
binding (10.7).
10.5 Layout constants (baked)
Baked compile-time defaults — override per-node, on the root, in rules, or in an
instance / link block. The per-property values and the template bundles live in
the Property Ledger — every baked default has
one home (the implementation’s ledger module), so the whole look is tuned from
one place. The root’s padding (20) frames the whole scene — the SVG margin.
The constants below are engine anatomy — chrome with no ledger row, and the link
seats a drawing or schematic scope lays under the ledger’s own defaults
(SPEC 17) — listed here once. So are the
chart-plane sizes (SPEC 14.1): a chart defaults
360 × 220, a pie / radial chart a 280 square — a chart cannot size to its
content, so these stand in for auto.
The chart type scale and its stand-off (SPEC 14.6):
chart title 15 chart text 12 (every other string a chart draws)
chart gap 15 (the plot's gutter to its title / legend)
chart clearance 6 (the scope's config — text off what it labels)
The drawing chrome (SPEC 15) — sheet-space, never scaled:
dim clearance 18 (the drawing scope's link default)
dim-ext-gap 3 dim-ext-overshoot 3 halo-margin 2
dim-arrow 12 × 4 datum-triangle 11
note-offset 20 note-landing 12 note-text-gap 5
hatch-pitch 6 hatch line-width 0.75 break-gap 12 tol-stack 0.7
center-mark-overhang 4 drawing link stroke-width 1 drawing link font-size 12
plane-overhang 6 (then the 10 thick end, standing in it) plane-arrow-shaft 13
plane-letter-gap 7 plane-letter 12 title-field width 96 (a title-block field's floor)
The schematic chrome (SPEC 16) — sheet-space:
schematic gap 100 (the coarse lattice — the part pitch, five fine ones; SPEC 16.1)
pin-pitch 20 (the fine lattice: pins, stub tips and wire tracks — and the
schematic world's track quantum, ROUTING.md)
pin-stub 20 junction 4 (radius) tag-point 8 (a flag's nose reservation; the nose draws at 45°)
net-label-run 40 (2 × pin-pitch — the floor on a plain net label's run of
trace; a longer name grows it, `width:` raises the floor — SPEC 16.4)
net-label-offset 4 (the clear space that name keeps off the trace, and off
the run's two ends)
pin-number offset 9 (across the lead) readout offset 24 (beside a turned part's axis)
readout gap 8 (part edge → its ref / value) readout stack 4 (between the two)
schematic clearance 10 (the scope's config; past pin-pitch it errors, SPEC 16.1)
schematic link stroke-width 1.5 corner-radius 0 (the scope's link default)
10.6 --static
Class rules and inline style= work everywhere, but CSS variables don’t — resvg
and librsvg fail var() in every position (browsers, even <img>-embedded, are
fine) — and neither honours @font-face. --static
keeps the rules but inlines every var(--lini-name) as its literal
and outlines text to paths (SPEC 18): no runtime theming, but
a self-contained SVG that renders identically anywhere, installed fonts or none.
10.7 Expressions & functions
A parenthesized expression (…) holds compile-time math — folded to a literal (a
number, or a point (x, y) for geometry) when the diagram compiles. Parentheses are the
only place operators appear: outside them - is a link or a number’s sign, < / >
are markers, // a comment, so the parens are what let * mean “times”. A value stays
paren-free until an operator does. A call’s own parens count, so an operator
inside a call’s arguments needs no inner group — what makes math usable inline
everywhere; a signed number is a sign, not an operator, so -2 stays bare
(translate: -35 20) — to subtract, group it:
gap: 8; // a literal — bare
width: scale(3); // a call — bare, no group
padding: (8 * 2); // an operator → a group (= 16)
draw: move(-2, 5) up(8) // calls and signed numbers — bare
draw: right(w / 2) // an operator in a call's own parens — no group
Inside a group the language is small and total:
- Operators
+ - * / ^(^power, right-associative), unary-, grouping( ), comparisons< <= > >= == !=, the ternarycond ? a : b. - Functions — the math library
exp ln log sqrt abs sin cos tan min max clamp floor round pow, and any you define (below); each returns a number or a point, calledname(args). (Colour / track builders likergb/repeatmake typed values, so they live in value position, never inside math.) - Constants
pi,e; scientific notation1e6,1.32e-6; the sample parametersu(geometry, below) and chartx; and your bound names, read bare (below). A bare name resolves: locals → the ambient (u/x) →pi/e→ your bindings. - Locals —
name = expr;binds for the rest of the group; the final expression is the value (no keyword, noreturn).=binds,==compares. A top-level,makes the value a point. Values are numbers and points — no strings, no loops.
(r = 40; n = 6; 2 * pi * r / n) // r, n are locals; the last line is the value
Bindings are written in the stylesheet with = — a name bound to a value, for reuse
in any expression. A scalar is name = value; a function adds a parameter list,
name(params) = value. The value is bare when it is a literal, a name, or a call,
and a group when it holds an operator, locals, or a point. = binds and reads
compile-time (baked), where : sets a live property — the two never meet:
{
my_radius = 5; // a scalar — read bare as `my_radius`
scale(n) = (100 * 1.2^n); // a function
wave(a, f) = (u*300, a*sin(2*pi*f*u)); // a function returning a point
}
|sketch#part| { draw: move(-my_radius, 0) right(2 * my_radius) up(my_radius); }
|box| { padding: my_radius; } // a scalar is a value wherever one goes
Call a binding anywhere a value goes — bare like rgb(…) / repeat(…), or inside a
group; a computed argument rides the call’s own parens
(|box| { padding: (scale(2) + 4); columns: repeat(3, 80 * 2) }).
Geometry. points: (on |line| / |poly|) may be a parametric expression in
u — u sweeps 0 → 1, sampled at samples: points into a vertex list, drawing
curves, waves, and spirals procedurally:
|line| { points: (u*300, 20*sin(2*pi*3*u)); samples: 60 } // a sine wave
|line| { points: wave(20, 3); samples: 60 } // the same, named
Everything an expression touches bakes — a computed size, a sampled curve — so a
standalone SVG never depends on host CSS. The same sample-an-ambient seam feeds a
chart’s fn: (with x bound to the domain — SPEC 14). Unknown names, wrong
arity, and out-of-range results are compile-time errors (SPEC 21).
A container picks an engine with layout:. Every engine reads the same core
(Part I) — the cascade, paint, text, the box model, links, colour — and
adds only its own placement algorithm, its own child roles, and a few scoped
properties. This part is the family; each section states just its delta.
11. The Layout Model
layout: | Engine | Arranges its children as | Wiring strategy | Lowers its subtree? |
|---|---|---|---|---|
flow (default) | 1D flex | boxes / text in a row or column | orthogonal router | no — arranges in place |
grid | 2D grid | boxes / text in tracks | orthogonal router | no — arranges in place |
stack | datum | every child’s origin on one datum (SPEC 12) | orthogonal router | no — arranges in place |
tree | rooted hierarchy | topics in generations (SPEC 12) | router (orthogonal / natural) | no — arranges in place |
sequence | time axis | participants + messages + frames + notes (SPEC 13) | time-rows → the straight strategy | yes |
chart | data plane | series + axes + bands + marks (SPEC 14) | layout-time data→pixels | yes |
pie | part-to-whole | slices (SPEC 14) | layout-time value→angle | yes |
drawing | datum / geometry | geometry + annotations + mates (SPEC 15) | layout-time dims / leaders | yes |
floorplan | the drawing engine, architectural dialect | walls + openings + fixtures + annotations (SPEC 15.11) | layout-time dims / leaders | yes |
schematic | circuit sheet | anchors on tracks + satellites at pins (SPEC 16) | orthogonal router, fixed ports | no — arranges in place |
Defaults. Every container — the root included — defaults to layout: flow with
direction: row and gap: 36 — source order flows the way it reads, so
cat -> dog -> bird runs left to right (a closed shape’s or a |topic|’s children
are card content, not arranged nodes, so those stack instead — direction: column,
gap: 12 — and an icon sits over its label; |block|, |group|, and the frameless
wrappers are containers and keep the flow pair); padding defaults per SPEC 10.5,
the root’s framing the whole rendered scene — links and labels included — out to the
SVG edge.
Three seams every engine plugs into
The engines differ, but three contracts are shared — which is why a new layout is a small, bounded addition (Part III formalises each):
-
The smart label extends. The one label rule (SPEC 3) — each type places its
"X"— is inherited by every layout (title, legend, axis title, header, guard; SPEC 13, SPEC 14). No layout invents a label syntax. -
The wiring strategy realises a scope’s links.
flow/grid/tree— andschematic, whose wires land on fixed ports (SPEC 16.5) — hand their links to the router (SPEC 9, ROUTING.md);sequencefixes each message’s geometry (column x, row y) and hands it to thestraightstrategy; adrawing— and itsfloorplandialect (SPEC 15.11) — lowers each link to a dimension, leader, or mate (SPEC 15);chart/piehave no links. One scope, one strategy — set by the scope’slayout(withrouting:selectingorthogonal,natural, orstraightfor the routed ones), and it governs that scope’s own links only — an ordinary|row|/|grid|nested inside a sequence or drawing still hands its internal links to the router (a schematic is the one scope whose link laws also reach links written in its nested ordinary containers — placement never cascades, SPEC 16). Asequencemessage is thus the one place a link’s order is its geometry, not a routing problem. -
A layout-owning engine lowers to primitives in the layout phase.
flow/gridarrange their children where they sit.sequence/chart/pie/drawing(floorplanincluded) instead read their whole subtree and emit an ordinary primitive tree —|block|s,|line|s,|path|s, text — at baked coordinates (SPEC 19). So the cascade, palette, theming, gradients,--static,fmt, and determinism all apply to a chart, a sequence, or a drawing with no engine-specific render code — a chart is a diagram once lowered.
The container is still a box. An engine owns where its children go, but the
container node itself is an ordinary box: its own fill, stroke, stroke-width,
radius, opacity, shadow, rotate, and href paint in every layout — a chart,
a sequence, or a pie can carry a background, a frame, or a link like any |box|.
Universal container properties
The container property set — which engine honours which is SPEC 17’s matrix, the authoritative table.
gap is honoured everywhere but means what the engine needs: inter-child spacing in
flow / grid, generation distance × sibling separation in a tree
(SPEC 12), the plot-to-title/legend gutter in a chart / pie
(default 15), and the message pitch / participant spacing in a sequence (default 32); a
drawing places by datum and ignores it (its mates read a scoped gap: of
their own — SPEC 15.5). direction, align, justify, and
gap-fill are the flow / grid arranger’s knobs — a stack, sequence, chart /
pie, or drawing container places its own children and ignores them, and a tree reads
direction and gap alone. padding is honoured wherever it has meaning
(the matrix, SPEC 17).
Nested boxes are unaffected. These knobs govern a container engine’s placement of
its own children; an ordinary box nested inside any layout still lays out its own
content by the box model. So a participant box in a sequence — an ordinary box —
honours padding, align, justify, and gap-fill on its own content, even
though the sequence engine placed the participant on the time axis. (A chart / pie
consumes its children into marks, so this case does not arise there — SPEC 14.)
gap-fill (default none) fills a flow’s or grid’s interior gutters — the gap
regions between children — with a colour, thickness = the gap (gap: 1; gap-fill: --stroke
paints hairline rules). Per-axis gap picks which rules show (gap: 1 0 row rules, 0 1
column). Gutters are interior only — the outer frame is the container’s own stroke,
never doubled — and span-aware in a grid (skipping pinned and spanning cells). This is what
makes |table| plain grid + gap: 1 + gap-fill: --stroke, not a magic type (SPEC 8).
12. Flow, Grid, Stack & Tree
The router-routed layouts: they arrange boxes and text in place — or, for
stack, seat them on a shared datum — then hand their links to the router
(SPEC 9). flow is 1D flex, grid is 2D, stack one datum, tree a
rooted hierarchy.
Flex — align / justify
layout: flow runs its children along one axis, set by direction (row
horizontal — the default, and column in card content — SPEC 11).
justify runs along the flow (main axis), align runs across it (cross axis).
Both default center — so the knob that flushes a box’s text horizontally is
justify in a row box and align in a column one, a card included.
| Value | justify (main axis) | align (cross axis) |
|---|---|---|
start / center / end | pack at the edge / centre / opposite | align each child to the edge / centre / opposite |
stretch | fills children to span the main axis | each child’s box fills the cross axis |
evenly | equal gaps between and around children | (treated as center) |
origin | (treated as center) | children line up origin-to-origin |
stretch fills the child’s box, not its content (placed by the child’s own
align/justify, also center). evenly needs multiple children.
align: origin aligns what the boxes contain, not the boxes. Every node has
an origin — the bbox centre of an ordinary node, a |sketch|’s pen origin, a
pattern:’s seed datum, a |drawing|’s datum (SPEC 15.1) —
and origin puts every child’s origin on one shared cross-axis line, which is how
a row of drawings shares one axis
(SPEC 15.8). Where the line sits:
given an explicit cross size the group fits into, it is the container’s centre
line — a small part’s axis rides the sheet’s centreline; on an auto-sized (or
overfull) axis the group centres around the line instead, so a large ensemble
stays balanced. For ordinary children it is center; it differs exactly where
a box is asymmetric about its origin — a view whose dimensions stack on one
side. In a grid, both align
and justify accept it: the cell puts the child’s origin on its track centre, so
one row of cells shares a horizontal axis and one column a vertical one — the
projection-sheet arrangement.
All of align/justify/stretch/evenly are no-ops unless the container is
larger than its packed children — an auto-sized container has no slack to
distribute (origin is the exception: it re-lines children even without slack).
Slack comes from an explicit width/height, or a grid’s fixed tracks.
Grid — columns / rows / cell / span
A grid is sized by its track lists:
| Property | Notes |
|---|---|
columns | Required. A track list — columns: 80, 140, 80 (3 fixed), columns: repeat(3) (3 auto), or a mix (auto, 40, auto). The list length is the column count. |
rows | Optional. Same form. A floor, not a cap: extra children flow into implicit auto rows. Omitted → all rows implicit, count ⌈children / columns⌉. |
cell | A box child’s placement column row, 1-indexed (cell: 2 1). |
span | A box child’s span columns rows, default 1 1 (span: 2 = 2 1). |
A track is a size (80), auto (sized to its widest/tallest child), or
repeat(N) / repeat(N, size) for many equal tracks. The count comes from the
list length. There is no fr unit. A fixed track is a floor like an
explicit width (SPEC 5) — it grows to its widest child,
so a grid never clips.
Auto-flow. Children without cell: flow left-to-right, wrapping at the column
count; a cell: pins one explicitly and the rest flow around it. Bare-text cells are
pure auto-flow — cell: / span: apply to box children only (a text
node has no block to carry them). A grid is positional, so an empty "" cell is
kept — it holds its track and keeps the cells after it aligned (in flow, an
empty "" is dropped). cell: is read on a grid and on a schematic
(SPEC 16.1), span: on a grid alone; where the
container’s layout is statically known to be neither, they are an error
(SPEC 17’s strict rule, SPEC 21: 'cell' places a grid or schematic child — this box sits in a 'layout: flow').
Per-column alignment. On a grid, align (horizontal ↔) and justify
(vertical ↕) accept a list parallel to columns (one value per track) or a
scalar for all — so align: start, center, end aligns three columns in one
declaration. Mind the axes: a grid follows column-flow, not CSS grid, so align
is horizontal — the same knob that left-aligns text in a direction: column box.
stretch fills the track; start/center/end pack the cell’s box at natural
size; the default centres.
A cell that fills its track (stretch) then honours its own align/
justify to place its content: an auto cell has no slack and sits centred, but a
filled one slides its text to the aligned edge — what lets a |table| align a
whole column (SPEC 8) with no notion of “table” in the core.
The same knob aligns a multi-line text’s lines (SPEC 6).
Stack — one datum, no flow
layout: stack arranges nothing. Every child’s origin lands on the container’s
datum and translate: is the only offset — not its bbox centre, so a symmetric
primitive (whose origin is its centre) stacks concentric, while a |sketch|’s
origin is its pen origin and several sketches keep the relationship they were
drawn in. That last property is the point: flow arranges sketches side by side and
throws away the frame a pen was drawn in, so co-registered geometry — artwork, a
hand-placed figure, a diagram tuned past what an arranger will do — has nowhere to
live without it.
{ layout: stack }
|sketch#body| { draw: move(0, 0) right(40) down(20) close() }
|sketch#slot| { draw: move(10, 5) right(20) down(10) close() } // same frame
|stack| is |block| + layout: stack — frameless, padding 0. The container is
still a box (SPEC 11): it paints, sizes to its children, and
places in an enclosing flow like any other, so a stack nests inside an ordinary
diagram and vice versa. Its links go to the router, so arrows, labels and
routing: behave exactly as they do in a flow — absolute placement does not cost
you the diagram.
gap, direction, align, justify, and gap-fill have no meaning where nothing
is arranged and are ignored (SPEC 11) — the same answer a
drawing gives. (A root block refuses them outright, as it does for every engine
that arranges nothing.)
A stack measures in pixels. unit: and density: read here as they do on a
drawing (SPEC 15.1), but the default is
unit: px — one drawing unit is one pixel, 1 : 1 — because a canvas wants the number
it typed. State unit: mm (and a root density:) to draw in millimetres instead.
drawing is this engine plus drafting. layout: drawing (SPEC 15)
is a stack that also mates parts, lowers links to dimensions and leaders, and
generates chrome; floorplan is that under an architectural vocabulary. The
placement law above is stated once, here, and all three read it — which is why a
|sketch|, mirror:, and pattern: work identically in every one of them, and why
only the drawing half draws a fused mirror’s centreline.
Tree — rooted structure
layout: tree arranges a rooted hierarchy. Structure is |topic| nesting
(SPEC 8): a direct |topic|-derived child is a branch,
every other child the topic’s own content; custom structural types derive from
it (|person::topic|). The scope holds exactly one root topic (none or two
errors; a forest is beyond 1.0 — SPEC 24), and |topic| outside
a tree scope errors (SPEC 21).
direction: | Growth | The look |
|---|---|---|
column (default) | down from the root | org chart |
row | rightward from the root | logic tree / outline |
bilateral | both sides, horizontally | mindmap |
Placement is post-order: each subtree packs its children at the cross-axis
gap (sibling separation), the parent centred over its subtree’s span one
main-axis gap (generation distance) away; subtrees never overlap.
bilateral splits the first level — the first ⌈n/2⌉ first-level topics
fill the right side top-to-bottom in declaration order, the rest the
left (each half the row layout, the left mirrored, the root centred
between them); a first-level side: left | right overrides its half.
top / bottom there, or any side: on a row / column topic, is an
error — there is no vertical bilateral; growing downward is column.
Branch links are generated, and ordinary. Desugar adds one unmarked fan
per parent — ceo:bottom - ceo.cto:top & ceo.coo:top — written in the scope
that contains the parent, with the direction’s forced sides (column:
bottom → top; row: right → left; bilateral mirrors per half, the
root emitting both sides). lini desugar shows them; the scope’s routing
draws them like any wire; and a link into one’s own descendant cascades as if
written in that node (SPEC 4), so
#cto |-| { } restyles exactly cto’s arm. An anonymous topic gets a
deterministic minted id — lini-topic-N, 1-based among its scope’s topics —
so its wires exist; an authored id is used as-is, and may not begin lini-
(SPEC 23). Authored cross-links stay legal, never alter
the tree, and keep the neutral link default. Every topic also wears a
generated .lini-level-N class (root 0), so one rule restyles a tier
(.lini-level-2 { font-size: 12 }).
The engine reads direction and gap alone (gap: g s — generation, then
sibling; a scalar sets both; a tree scope defaults gap: 64 48, room to route
at the default clearance). A plain tree is neutral — uniform topics, elbow
connectors from the default routing: orthogonal; the mindmap look is the
|mindmap| preset (SPEC 8).
13. Sequence
A sequence reads a diagram on a time axis: layout: sequence places named
participants across the top, drops a lifeline from each, and lays messages —
ordinary links — top-to-bottom in source order, so the order you write the wires is
the order they happen. It adds no grammar: participants are nodes, messages are links
(SPEC 9), frames and notes are nodes — only the engine, six type names, and two
properties (place, activation) are new, and it lowers to primitives like any layout-owning engine
(SPEC 11, seam 3).
The container & its children
layout: sequence on the root ({ layout: sequence }) or any node makes a sequence; the
|sequence| template is the preset over |block|. Its children split by role,
recognised by type — every other box is a participant (an open fallback, unlike a
chart’s closed series set):
| Child | Is a | Drawn |
|---|---|---|
a box (|box|, |cyl|, |icon|, …) | participant | a header at the top + a lifeline down |
a link (a -> b) | message | a time-row arrow between two lifelines |
|loop| / |opt| / |alt| | frame | a labelled rectangle around a span of messages |
|else| | separator | a guarded compartment divider inside an |alt| |
|note| | note | a callout over / beside lifelines |
Nodes and links interleave in source order (SPEC 9), so a frame (a node) sits among the messages (links) around it.
One scope. Every message resolves its endpoints against the sequence’s
participants, whatever frame it sits in: a frame’s [ ] groups messages for layout but
opens no new scope — it declares no participants and auto-creates none, overriding the
sealed-body and body-auto-create rules (SPEC 3, SPEC 9)
inside a sequence. So |alt| [ db --> api … ] wires the outer db / api lifelines,
never frame-local boxes.
gap sets spacing: its column part the space between participants, its row part
the gap between message rows (gap: row col; default 32 32). A label wider than its span
widens it — adjacent lifelines sit max(gap-column, widest message label between them + margin) apart, text measured at compile time. width / height size the whole frame and
distribute any surplus; unset, it sizes to its content.
Participants & lifelines
A participant is an ordinary node; its smart label is its header, placed by its
type (SPEC 3) — centred text for a box, the symbol for an |icon|.
Participants sit across the top in declaration order (left to right), each dropping a
lifeline and sharing a common foot at the last row. An undeclared endpoint
auto-creates a participant — a -> b with neither declared draws two — appended in
first-use order, so a quick sequence needs no header:
{ layout: sequence }
user -> server "login" // two auto-created participants, one message
server --> user "token"
Declare a participant (with an #id, so messages can name it) to fix its order, type, or
paint: |cyl#db| "Store", or |icon#user| "user" for an actor glyph. A participant
lends its paint to its apparatus: its lifeline and activation bars take its own
fill / stroke / stroke-width, so colouring or weighting a participant carries down
its whole timeline. Being an ordinary box, it also honours the box model on its own
content (SPEC 11).
Messages
A message is a link (SPEC 9) read on the time axis: its operator picks the look, its label rides above the arrow, its order is its row.
| Write | Means |
|---|---|
a -> b "x" | a call — solid arrow, a to b |
a --> b "x" | a return — dashed arrow |
a ~> b "x" | an async message — wavy arrow |
a -> a "x" | a self-message — a hook on a’s own lifeline, label to the right |
Every operator, marker, class, and { } is the link’s own; only the placement differs,
so a message’s label sits centred above its arrow and along: has no role. A chain
a -> b -> c is two messages on two rows; a fan a -> b & c likewise expands to two, in
expansion order. A forced side (a:left) and routing have no meaning on a time-row arrow
and are ignored.
Call vs. return is read from the operator (-> vs -->), not a stroke-style:
override.
Activations
A participant is active while it handles a call. By default (activation: auto) a
call (->) opens an activation bar on its target’s lifeline; the next return
(-->) from that target closes its most recent open bar; nested calls stack (each
bar offset outward), and an unclosed bar runs to that participant’s last row. The bar
stack is sequence-global — a call inside a frame may close outside it. A self-message
(a -> a) and an async (~>) open none, and a return with no open bar just draws its
arrow. activation: none on the sequence draws no bars. (Explicit per-message control is
deferred — SPEC 24.)
Frames & notes
A frame is a node whose [ ] holds its messages, drawn as a dashed rectangle spanning
the lifelines those messages touch (plus a small inset) over the rows they occupy. A
top-left tab names the operator; the frame’s smart label is its guard, drawn
as the first compartment’s condition. Frames nest and draw behind the lifelines (a
fill tints the region without hiding the wires):
| Frame | Means |
|---|---|
|loop| "guard" | the messages repeat (drawn once, not unrolled) |
|opt| "guard" | the messages happen only if the guard holds (an if) |
|alt| "guard" | one of several alternatives (an if/else) |
An |alt| holds two or more compartments split by |else| "guard" — a separator
valid only inside an |alt|, its label that branch’s guard; the first compartment’s guard
is the |alt|’s own label:
api -> db "query"
|alt| "found" [
db --> api "row"
|else| "missing"
db --> api "404"
]
api --> user "done"
A |note| is a callout placed at its time row (source order), bound to lifelines by
place: — a mode, then its lifeline id(s): { place: over api } a box over one
lifeline, { place: over api db } a box spanning those (and any between),
{ place: left api } / { place: right api } a box beside one. One mode per
note. Its smart label is the text; a multi-line or styled note rides the [ ]
like any box. place: is valid only in a sequence. par and other fragments are
deferred (SPEC 24).
Defaults
The five sequence types are bundles over |block|, tuned to read with no styling; the
cascade overrides any of it, and they reuse the scene’s role variables — no new ones.
(|note| is the core template, compacted here by its built-in scoped rule —
SPEC 8.)
| Type | Defaults over |block| |
|---|---|
|sequence| | layout: sequence; gap: 32 32 (a root { layout: sequence } gets the same gap) |
|loop| / |opt| / |alt| | fill: none; stroke: --group-stroke; stroke-style: dashed; stroke-width: 1; radius: 4; padding: 24; font-size: 12 |
|else| | fill: none; stroke: --group-stroke; stroke-style: dashed; stroke-width: 1; font-size: 12 |
The engine resolves in the layout phase — a message’s x-ends are the lifelines’ positions
(fixed once participants are placed) and its y is its row — placing participants, walking
messages/frames/notes in source order, and lowering headers → |block| + text, lifelines
and arrows → |line|, activations/frames/notes → |block| (SPEC 19).
The orthogonal router never sees these links.
14. Charts
A chart is a layout — layout: chart and layout: pie — so the cascade, paint roles,
the "string" rule, the expression engine, lower-to-primitives, theming, and baking all
apply unchanged (SPEC 11). A chart’s one new job over row/grid is
to read all children first, fix a shared scale (data domain → plot pixels), sample
any formulas, then lower each child to primitives at baked pixel coordinates — the chart
analogue of a grid sizing tracks from its children. Charts add no grammar: the new
surface is type names, properties, and the layout algorithms.
14.1 The chart plane
| Layout | Template | Encodes | Children |
|---|---|---|---|
layout: chart | |chart| | an x/value plane (cartesian or radial) | series, |axis|, |band|, |mark|, |bubble| |
layout: pie | |pie| | part-to-whole, value → angle | |slice| |
width / height set the whole chart (plot plus axis gutters and legend); the plot
area is the remainder after labels are measured. Unset, the plane takes its baked size
(SPEC 10.5) — a chart cannot size to its content
(the content depends on the scale, which depends on the size), so a constant
stands in for auto. fill is the chart background, stroke
its frame, and the cascade styles a chart like any box.
Chart-level properties (on the |chart| / |pie| node):
| Property | Layout | Value | Default |
|---|---|---|---|
direction | chart | column · row · radial | column |
bars | chart | grouped · stacked · overlay | grouped |
categories | chart | quoted-string list — the x-axis (or spoke) labels | indices 1…N |
samples | chart | integer — fn: sample count | 24 |
hole | pie | 0 ≤ n < 1 — inner-radius fraction (a donut) | 0 |
legend | both | top · right · bottom · none ⌛ (SPEC 24) — writing it is an error until the reader lands | auto (shown when ≥ 2 entries) — built |
tooltip | both | none · hover · auto · always (14.8) | auto |
gap | both | number — clear space between the plot and the title / legend outside it | 15 |
clearance | both | number — the daylight every chrome text keeps off what it labels (14.6) | 6 |
categories sets the x (domain) axis’s tick labels — the one form today;
explicit per-axis tick text is deferred (SPEC 24).
14.2 Series
A series is a child node; its smart label is its legend entry (no label → no entry).
Each series lowers to primitives and is valid only inside its layout (a series elsewhere is
an error, like cell: off a grid):
| Series | Layout | Draws | Lowers to | Paint |
|---|---|---|---|---|
|line| | chart | a polyline through the data (a closed loop when radial) | |line| / |path| | stroke, stroke-width, stroke-style |
|area| | chart | a line filled to a baseline | |poly| / |path| + |line| | fill, stroke, baseline |
|bars| | chart | one bar per datum (a wedge when radial) | one |rect| / |poly| each | fill, stroke, radius |
|dots| | chart | one marker per datum | one |oval| / marker each | fill, stroke, marker |
|bubble| | chart | one bubble at a point, sized by value: | one |oval| | fill, stroke |
|slice| | pie | one wedge | one |path| | fill, stroke |
Singular vs. plural is the cardinality: |line| / |area| are one shape;
|bars| / |dots| a set of marks, one per datum; a |slice| / |bubble| one
each, per node.
Inside a chart, |line| reads data: / fn: (data space); the standalone |line|
primitive (SPEC 7) reads points: (pixels) — the chart layout branches on which.
A line carries markers at every datum, reusing the core marker: family generalised
from line ends to every vertex: |line| { marker: circle } shows a marker at each point.
A chart marker is centred, so only the symmetric kinds apply — dot, circle
(a larger, hover-sized point), and diamond; the directional arrow / crow are an
error on a series (SPEC 21). Every marker carries the datum’s <title> — a
marked point is a hover target (14.8). |dots| is markers with no line,
circle by default; its diameter is width (height too for an ellipse), its shape
marker: — there is no size: property.
curve: sets a line’s / area’s interpolation: linear (default, straight segments),
smooth (a monotone cubic — curved, passes through every point, never overshoots;
parameter-free), or step (a staircase). bars: on the chart combines multiple
|bars| series: grouped (side-by-side, default), stacked (piled; the top is the sum),
or overlay (translucent, on top). radius rounds a bar’s corners. (Stacked areas are
deferred; areas overlay.)
A |bubble| is one mark per node — |bubble| "Name" { at: x y; value: N; fill: … }
places a bubble at data point (x, y), sized by value:. The chart scales bubbles by
area (area ∝ value); the smart label sits centred in the bubble when it fits — inside
the disc inset by clearance, on the chord at the text’s own half-height —
else beside it, else on hover. Reach for |bubble| when each is a distinct labelled entity; for many uniform
points, |dots| is terser.
14.3 Data & formulas
A series’ values come from data: (explicit) or fn: (computed) — never both. Both use
the core comma law (SPEC 2), so charts add no value form;
the item width is the discriminator:
| Source | Syntax | Meaning |
|---|---|---|
| categorical | data: 9, 15, 24, 18, 30 | scalar items → one value per category |
| points | data: 0 225, 60 225, 118 221 | x y items → points (numeric x; scatter) |
| formula | fn: (min(8/(x/100-1)^2, 2000)) | an expression in x, sampled at samples: |
Scalar and pair items never mix in one data: — data: 10 20 is one point
(SPEC 2); a space-separated list errors with the comma form.
A |line| / |area| needs ≥ 2 vertices; with categorical data the value count must match
the categories: count (SPEC 21).
A point’s x may be a date — a quoted ISO-8601 literal: data: "2026-01-01" 18, "2026-02-01" 25. YYYY-MM-DD, optionally THH:MM[:SS], optionally Z / ±HH:MM;
a bare date is date-only (midnight UTC), an offset keeps its instant, and rendering is
timezone-independent (all math in UTC). Date x-values make the x axis a time axis
(14.4); dates and plain numbers never mix in one domain,
and an invalid date is an error (SPEC 21). Time-only literals don’t exist —
a numeric axis covers them.
labels: is the per-datum text — a quoted-string list parallel to data:
(one entry per value or x y point), distinct from the series’ one legend label
(its smart label). An entry rides with its datum: on the plot beside the point, or
on hover when there’s no room — the placement is tooltip:’s job
(14.8). The count must equal the data count; labels: needs
discrete data: (a sampled fn: has no authored points, so labels: with fn: is
an error). A per-node mark (|bubble|, |slice|, |mark|) takes no labels: —
its one smart label is its point label.
|line| "GLM-5.2" { data: 35 63, 42 72, 84 75; labels: "Non-Thinking", "High", "Max"; marker: circle }
Formulas are the core expression engine (SPEC 10.7):
operators, the math library, name = expr; locals, the ternary, and stylesheet functions.
Charts bind two ambient names — the same seam that injects u for parametric points::
x the x-axis data value (a whole-domain fn: uses it) and u a band-local clock
0 → 1 (14.5). A fn: is not folded at resolve
(its x is unbound there) but held and sampled at chart layout, once the x-domain is
fixed — a (…) group, a bare call (fn: sin(x)), a name, or a constant alike.
Locals chain derivations in one group; a stylesheet function keeps twins DRY:
{ ramp(s) = min(100, 25 + 1.572*(x/s) + 0.0142*(x/s)^2); }
|chart| [
|area| "Steel" { fn: ramp(1) }
|line| "Aluminum" { fn: ramp(1/0.7) }
]
The formula ceiling. fn: expresses a function of x, not a recurrence: a numeric
integration (a running sum) has no closed form and ships as precomputed data: points.
14.4 Axes, scales & domain
An axis is an |axis| child of a layout: chart (an #id is optional, used to bind —
a series or annotation reads an axis with axis:); its smart label is the axis title.
A chart with no |axis| gets an x (domain) axis and an auto-fit value axis, so simple
charts declare none — an axis is written only to say something.
| Property | Value | Notes |
|---|---|---|
side | bottom · left · right · top | cartesian only; several on one side stack outward in source order |
range | a b (each end a number, a quoted date, or auto) | the data window — and crop, and reverse (below) |
scale | linear · log · time | log walks the decade ladder — 1-2-5 per decade, thinning to the room (below); its domain must be above 0. time reads date literals (below) |
step / ticks | number / list · calendar (time) | tick spacing, or explicit ticks; omitted → nice ticks |
format | family + args (SPEC 17) | tick-value presentation; inherits from the chart |
unit | "%" | a quoted suffix appended to tick labels (and tooltips) |
gridlines | none · colour | this axis’s gridlines: none, or a colour (a colour turns them on) |
stroke / color / font-size | core | stroke tints the axis line + ticks, color the labels + title |
An x (domain) axis is categorical when categories: gives it labels (or by default,
indices 1…N) and numeric when the data is points or a fn:. A value axis carries
series magnitudes; axis: <id> on a series binds it (default: the first value axis of the
series’ orientation). Multiple value axes share a plot for dual-unit charts; only the
primary value axis and the x axis draw gridlines by default, so a normal grid appears
and a second value axis adds none (avoiding moiré). The default tint is the
--lini-grid role variable (SPEC 10.1).
range: a b does three jobs at once: it sets the visible window (a…b),
crops data outside it to the plot area, and reverses the axis when a > b
(range: 50 1 runs high→low — both scale and tick order flip). Either end may be auto
(range: 0 auto); the two ends must be distinct (SPEC 21). Ticks are “nice” by
default (1-2-5 × 10ⁿ); step: sets a spacing, ticks: an explicit list, scale: log
decade ticks (domain above 0).
A log axis thins to its room. The decade ladder has rungs — 1-2-5 per
decade, the decades alone, then every second, third, … decade — and the axis
draws the densest whose ticks stand a text row apart along its own extent.
Only the drawn ticks change: the domain, and so every datum’s position, is the
ladder’s and not the rung’s. An explicit ticks: is never thinned.
Tick labels come from categories: (an x axis) or the
formatted tick value + unit: (a value axis) — format: sets the value’s presentation
(SPEC 17);
labels: is the series’ per-datum text (14.3).
scale: time — a numeric domain in epoch seconds, set by date literals in data:
(14.3). Everything measured on a dated domain is written in the
same literals — range:, ticks:, a |band|’s range:, a |mark|’s at:
(14.5) — and the other kind is an error, either way round:
one domain, one kind. Ticks are
calendar-aware: auto picks the boundary unit from the span (years → months → weeks →
days → hours → minutes) and lands on calendar boundaries; step: takes a calendar
interval — a unit ident with an optional count (step: month, step: 2 week) — and a
plain number errors, pointing at the calendar form (SPEC 21). Tick text
follows the tick unit (years read 2026, months Jan 2026, days Mar 4, finer
04:30); an explicit format: date preset wins (SPEC 17).
14.5 Bands & annotations
Both are children placed in data coordinates; the model gives them for free.
axis: names the axis they measure against and is required on a |mark|.
A |band| partitions an axis and drives three things from one declaration: a
background shade, a tick (its smart label), and the segment boundaries every
series shares. range: a b is its data range on its bound axis: — the same
interval shape the axis itself reads (14.4);
fill: none makes it a divider + label with no shading.
|band| "Inject" { range: 1.4 3.1; axis: time; fill: --rose }
A series opts into segmentation with a per-band fn: list — one (…) expression
(or a bare constant) per band, comma-separated (fn: (u*10), 5, (ramp(2))), evaluated
in local u; a single fn: samples the whole
domain in x and ignores bands. Consecutive segments connect end-to-start (the riser is
drawn), so a jump is explicit. A per-band list whose length ≠ the band count is an error
(SPEC 21) — never a silent truncation.
A |mark| places a reference line, point, or label by value on a named axis, so it
survives a direction flip unchanged:
| Form | Draws |
|---|---|
|mark| "100 °C" { at: 100; axis: temp } | a reference line at value 100, across the plot perpendicular to temp |
|mark| "60 °C — 19 min" { at: 19 60; axis: temp } | a point (dot + label): x = 19, value 60 |
|mark| "safe" { at: 170 4; axis: temp; marker: none } | a label only (no dot) |
at: V (one value) is a line, at: X Y (two) a point; marker: none suppresses a point’s
dot, leaving the label — so there is no separate free-label node. A reference line’s
label stands clearance clear of its line, never on it — beside the top
end (a row chart’s, the bottom), taking the other flank where the plot’s edge
is nearer (14.6). Bands and marks render in
column and row directions; in radial they are a compile error until built
(SPEC 21, SPEC 24) — never a silent drop.
14.6 Legend, title & colour
One smart-label rule, placed by where the label sits: on the |chart| / |pie| → the
title (a caption above the plot); on a series / |slice| → a legend entry with a
swatch mirroring its paint (fill and edge); on an |axis| → the axis title; on a
|band| → a tick tinted its fill; on a |mark| → the annotation’s label. A
legend appears automatically at ≥ 2 entries (legend: is deferred —
SPEC 24, 14.1).
gap holds the plot off its chrome; clearance holds a text off what it
labels. gap: is the gutter outside the plot — to the title, to the
legend. clearance: is the daylight inside it, measured
edge to edge on the ink: a tick off the plot edge, an axis title off its tick
row, a band’s name off the row above, a spoke’s category off the web rim, a
|mark|’s label off its line, a per-datum label off its mark. It is the
ordinary cascading clearance (SPEC 9) — a chart draws no links,
so this is its whole reading — and the plot yields the room, never the
label.
The type scale is two steps — the title, and every other string a chart draws — each stated by a rule, never inline on a leaf (SPEC 18). Weight separates the registers: chrome (title, legend) semibold, data text (ticks, per-datum and annotation labels) normal, so the numbers read quietly beneath the captions. A leaf carries only its own colour — a band tick tinted its fill, a mark its stroke.
Colour. Explicit stroke: / fill: wins. Otherwise series walk the palette
(SPEC 10.2) in declaration order, skipping red (reserved for
danger), repeating if exhausted — deterministic, and interleaved around the
hue wheel (adjacent series read as distinct, the common 2–4-series case getting
the strongest contrast):
--rose --teal --orange --sky --amber --purple --green --blue --lime --gray
(A mindmap’s branch walk is the different job — wheel order, red and grey skipped — SPEC 8.)
Each series takes its hue at the tier the role wants — the outlined look: a |bars| /
|area| / |slice| fills with the soft tier and gains a deep edge (stroke: none removes it — a flat fill); a line takes the deep stroke, dots the ink. An
explicit fill: keeps its colour and still gains a deep edge of it. In layout: pie the
walk is per slice — the one place colour walks per datum rather than per series.
Per-datum paint rides the comma law on the repeated-mark series — |bars| /
|dots| only: fill: / stroke: / opacity: take a comma list, one item per datum,
where auto is the paint that datum would get anyway (the walk, the deep-edge rule) —
fill: auto, auto, --red, auto highlights one bar, and with no authored stroke each
datum’s default deep edge deepens its own fill. The count must equal the count of
explicit data: (a sampled fn: has no authored data — SPEC 21);
the legend swatch keeps the series’ base paint. A list on
|line| / |area| is an error — one shape has one paint, no ambiguous interpolation;
|slice| / |bubble| / |mark| are already per-node.
14.7 Direction, radial & pie
direction orients the chart — the same property a flow uses to pick its axis, plus
radial: column (default, cartesian, bars grow up), row (cartesian, bars grow right),
radial (polar, bars grow outward). The flip is never silently lossy — nothing is
authored in screen coordinates (categories:, series data:, and annotations bound to
a named axis with at: / span: are all logical), so direction only changes how
that plane is projected, and what a direction cannot yet draw errors instead of
vanishing (a radial band / mark — SPEC 21). An explicit axis side: is a
screen edge and is honoured as written.
Radial (direction: radial) projects the cartesian model into polar coordinates: the
x (domain) axis bends into a ring (categories → evenly-spaced spokes, from the top,
clockwise) and the value axis becomes the radius. A radar |line| connects a series’
value on every spoke and closes to the first; an |area| fills that polygon; |bars|
fill their angular slot. A radial chart has one value (radius) axis — writing side:
on it is an error (SPEC 21) — and one x axis (the spokes). Concentric circular
gridlines and a configurable start angle are deferred; the polygon web is the default.
Pie (layout: pie) encodes value as angle — each slice’s angle is its value over
the total — a different scale from radial’s value-as-radius, hence its own layout. No axes;
its children are |slice| nodes:
|pie| "Spend" { hole: 0.5 } [
|slice| "Ads" { value: 40 }
|slice| "SEO" { value: 30 }
|slice| "Direct" { value: 30 }
]
A |slice|’s value: is its magnitude (≥ 0), its smart label its legend entry; slices
fill clockwise from the top, each angle = value / Σ value × 360°, and walk the palette
(so slices are distinctly coloured). A total of zero is an error. hole: (0 ≤ n < 1)
cuts an inner hole — hole: 0 a pie, hole: 0.5 a donut. On-slice value labels, a centred
total, and exploded slices are deferred (SPEC 24).
14.8 Tooltips
A datum’s label has two presentations, and one property — tooltip: — sets how much
shows where. Hover is the only interactivity, with no script:
tooltip: | On the plot (inline) | On hover | For |
|---|---|---|---|
none | — | — | a clean static plot, no labels |
hover | — | card + <title> | labels on demand |
auto (default) | where it fits, else falls to hover | card + <title> | the printable default |
always | every label, forced | card + <title> | export — every label must read |
The two texts complement: the inline label is the datum’s own text — a series’
labels: entry, or a per-node mark’s smart label — while hover shows its value. So a
point can read Max on the plot and GLM-5.2: 75% on hover, never competing.
The hover floor is always honest. A labelled mark carries a native <title> — its
accessible name, readable in any renderer and surviving --static. Over it, a live CSS
:hover rule reveals a hidden <g class="lini-chart-tip"> card built from primitives,
positioned beside the point; the card is live-only (a baked SVG keeps the <title> and
drops the :hover). Only tooltip: none strips the <title> too.
Inline placement is one greedy pass, not a solver: each label tries a few offsets and
takes the first that clears the labels already placed and stays in the plot (a seat must
also sit off the series lines). Under auto a label with nowhere to sit drops to its hover
card; under always it is placed regardless. Inline labels are small and muted (color:
overrides, default --muted) and carry pointer-events: none. tooltip: cascades: set on
the |chart| it defaults every series; a series overrides it. Hit targets stay sparse — a
sampled curve draws at samples: density but a marker sits only at data / turning points,
so node count stays bounded.
14.9 Lowering
layout: chart / pie resolve in the layout phase (SPEC 19), since the
shared scale needs every child’s data first: collect series and resolve data: /
sample fn:; fix each axis domain and scale (bars force zero); inset the plot rect
by measured label / legend gutters; lower every series, axis, band, annotation, and the
legend to primitives at baked pixels; emit in a semantic draw order — bands →
gridlines → areas → bars → lines → dots → annotations → axes → labels → inline labels →
tooltip — so a line sits above its bars without hand-ordering (the one place a chart
overrides source-order rendering; layer: still wins). The output is an ordinary primitive
subtree (SPEC 19).
15. Drawing
A drawing reads a diagram as a dimensioned sheet. Its placement is
layout: stack’s (SPEC 12) — every geometry child’s
origin on one shared datum — and everything in this section is what a drawing
adds on top: its links are annotations (dimensions, callouts, leaders) or
mates that seat parts against each other, and it generates the chrome drafting
always draws. One bet carries the
design: because the engine has the geometry in numbers, a dimension’s smart label is
its measured value — the numbers live once, in the geometry, and the annotations
point at them. Drawings are the one layout that substantially extends the grammar —
in the seven ways SPEC 22 enumerates; everything else is nodes,
declarations, and links, and it lowers to primitives like any layout-owning engine
(SPEC 11, seam 3). A drawing needs at least one
geometry child (SPEC 21); its children split by role:
| Child | Is | Drawn |
|---|---|---|
a box (|sketch|, |rect|, |oval|, |hole|, …) | geometry — a part or a feature | its outline and fill, at the shared datum |
a link with a measuring op — (-) (o) (<) — or a leader op — <- *- >- | an annotation | extension lines, arrows, text (15.6, 15.7) |
a link with || | a mate — or, with a sheet-content end, a seat | nothing — it positions a part or an annotation (15.5) |
any other two-ended link (->, <->, -->, -*, …) | a straight annotation arrow | one segment, markers per the op |
"…", |note|, |balloon|, |table|, |surface-finish|, |feature-control|, |datum|, … | sheet content | per its own type, sheet-space (15.1, 15.9) |
Four properties of the model, each inherited from the core:
- A drawing scope owns its links — the wiring strategy (SPEC 11):
the router never sees them; every one lowers at layout time to dimension or leader
primitives, or (for
||) to a position.routing:andalong:have no role on them;clearance:reads as a dimension’s stand-off minimum (15.6). - No auto-create. Unlike a diagram (
cat -> doginvents boxes), a drawing never invents an endpoint: an annotation must point at real geometry. An unknown endpoint is an error with suggestions (SPEC 21). - One placement model, whole scope. Every geometry child — and a part’s own
[ ]features, recursively — places its origin on the parent’s datum, not by flow. A child that owns a layout (a\|table\|, a nested\|drawing\|, a\|row\|…) lays out its interior as usual and places as one box. - What you measure is a node — or a point or edge it names. Anything dimensioned,
mated, or pointed at is a node with an id, or a
:segmenta\|sketch\|authors on its own profile (15.2, 15.3). Anonymous geometry is drawable but not addressable.
The geometry machinery is ordinary Lini, usable in any layout; only the annotation semantics need a drawing scope:
| Global — works everywhere | Drawing-scope only |
|---|---|
|sketch| + draw: / mirror: / revolve: / thread: / break:; pattern:; scale:; hatch() fills; stroke-style: center / phantom; |note| / |balloon| / |hidden|; the |page| sheet | the measuring ops ((-) linear, (o) round, (<) angle), the leader ops, ||, tol:, dim side: / project:, auto-measure, unit:, datum placement, the drafting-symbol types (|surface-finish| / |feature-control| / |control| / |datum|, 15.9), the chrome (centre marks, auto centerlines, dimension packing) |
Outside a drawing a \|sketch\| is just a shape; its authored :segments are declared
but dormant (a routed link landing on one is deferred — SPEC 24).
A floorplan is this same engine under an architectural vocabulary —
15.11, the one subsection that is a
dialect rather than a mechanism.
15.1 The container, the datum & the scale
|drawing| is |block| + layout: drawing — frameless, padding 0 (the geometry and
its annotations are the content). { layout: drawing } on the root makes the whole
file one drawing, exactly as a root sequence works; the root’s padding then frames the
sheet.
Datum & ground. The datum is the container’s own origin. Every child’s origin
lands on it — not its bbox centre: a symmetric primitive’s origin is its centre, so
primitives stack concentric by default; a |sketch|’s origin is its pen origin
(15.3), so two sketches drawn at different pen offsets keep
their drawn relationship. translate: x y offsets a child from the datum — the
universal nudge, unchanged. Children paint in source order (later on top), so
overlaps, punched holes, and cutaways compose with no boolean operations. The
ground is the first-declared geometry child: mates resolve by walking outward from
it (15.5); to reground, reorder the declarations.
Scale — three settings, one derived number. Numbers in a drawing are drawing units; three settings turn them into pixels and paper:
scale:— the drafting ratio, per view. Default 1;scale: 2reads 2 : 1,scale: 0.5reads 1 : 2, and the composed section / detail / view titles read it directly (15.8). Magnitude isscale:’s job — a 5 m beam on an A4 isscale: 0.02(1 : 50), never a resolution fudge.unit:— the physical size of one drawing unit:mm(a drawing’s default),cm,m,in, orpx— one unit is one pixel, the non-physical case, and a plainstack’s default (SPEC 12). Inherits nearest-wins (state it once, on the page); read in any datum scope — a|sketch|in a flow diagram stays pixel-space (right(300)is 300 px). Displaying a unit suffix on measured values is presentation —format:’s territory (15.6).- density — pixels per millimetre:
density: Non the root only, default 4. Non-semantic — it sets screen/raster resolution and nothing else: print stays true-scale regardless (SPEC 18), and no measured value, mate, or title reads it. Underunit: pxthere are no millimetres to convert, so only the identitydensity: 1applies and any other value warns (SPEC 21).
The engine’s pixels-per-unit is always derived — ratio × unit-in-mm × density
— never authored. Desugar folds the two it owns, unit: and density:, into the
scope’s internal px-per-unit: — its pixels per unit at ratio 1, so
lini desugar shows it (SPEC 19); the ratio is an ordinary
cascading property and multiplies it once, at layout, so a scale: from any tier
(or from an ancestor) reaches the view. Draw a 300 mm bar as right(300) at
the defaults and it renders 1200 px wide while every dimension still reads 300 —
measured values are always pre-scale; an absurd rendered extent draws a hint
naming the likely scale: fix (SPEC 21).
scale: is an ordinary node property, nearest ancestor wins: on the drawing it is
the view’s ratio (a 2 : 1 detail is a sibling drawing at scale: 2,
15.8); on any node it overrides — scale: 1 opts a
node out. One split makes it behave: a node’s position (translate:) scales by its
parent’s scale, its own shape (draw:, points:, width / height,
pattern: offsets) by its own — so a balloon in a 2:1 view stays beside its part at
true size. What never scales, at any setting: text (font-size is compile-measured,
per core), stroke-width, markers, hatch pitch, every dimension / leader constant
(SPEC 10.5), and a pinned overlay’s translate: —
a pin-relative nudge is chrome anatomy (a badge’s offset, the title’s gap), not a
position in the drawing. The |note| / |balloon| / |table| / |surface-finish| /
|feature-control| / |datum|
templates carry scale: 1 (SPEC 8) — annotations are sheet chrome — and
a define inherits its base’s side (|steel::sketch| scales, |finish::note| doesn’t).
Sizing & measurement. A drawing’s bbox is the union of its children’s paint
bboxes and its annotations (dimensions stack outside the geometry and count), plus
padding; an explicit width / height is a floor, per core. Measurement, by
contrast, uses each node’s geometry bbox — the drawn path, stroke excluded — so
line weight never leaks into a value or a mate. Geometry keeps the core
stroke-width: 2; a drawing’s links take the thinner sheet constants
(SPEC 10.5) — drawing-scope link defaults (like
the scope’s clearance / routing), below every user rule, so a plain
|-| { stroke-width: … } restyles them — the drafting 2 : 1 line-weight
contrast. Pen geometry, holes, shoulder lines, and the dimension/leader
linework paint the full drafting tone
(--stroke-dark — black on white); support lines the translucent
--stroke-light (SPEC 10.1). gap, align, justify, and direction have no
role on a drawing container and are ignored.
15.2 Anchors
The endpoint form is the core one (SPEC 9) with a wider point set, valid only in a drawing scope:
anchor = id { "." id } [ "." index ] [ ":" point ]
index = a 1-based pattern-copy number (15.4)
point = center (the default)
| top | bottom | left | right (side midpoints)
| top-left | top-right | bottom-left | bottom-right (corners)
| segment (authored in draw:, 15.3)
- Points sit on the node’s geometry bbox (15.1):
a side is that side’s midpoint, a corner the bbox corner,
centerits centre. Corners glue vertical word first, matchingpin’s vocabulary (pin: top left→:top-left); the reversed order errors with a did-you-mean. Corners and:centerare drawing-scope only — elsewhere the core four sides stand, with one exception: a sheet’s projection link (15.8). - A
|sketch|authors its own segments with the point sigil indraw:(15.3) — declared in the pen, selected on an endpoint, the same declare / select symmetry as#id. Built-in names win (:leftcannot be authored); an unknown segment errors with suggestions;mirror:copies of a segment are not addressable (SPEC 24) — apattern:copy is addressed by its index (15.4). - For measurement every anchor reduces to a representative point — a point is itself, an edge or arc its midpoint, a bbox name its bbox point — and a named edge additionally carries its direction, which sets a dimension’s axis and feeds the angular op (15.6).
- Dot-paths walk into children as everywhere (
pump.body:right), resolve in the statement’s scope, and never search (SPEC 9). A patterned node’s position is its seed copy (grid) or ring centre (radial), its other anchors read one copy’s geometry about that datum, and a numeric segment picks a copy outright —plate.bolt.2(15.4). - The anchor aims; the outline lands. A leader’s tip is a ray from its text toward the anchor’s representative point, stopped at the ray’s first crossing of the drawn path — aiming at the bbox corner of a filleted plate touches the fillet arc itself. Dimension extension lines, by contrast, spring exactly from the anchor points — except an edge anchor’s, which springs from the edge’s end nearest the dimension line (the drafting convention: the witness line leaves the corner, never travels the face — so it also never crosses a wall opening, 15.11). Measurement is untouched — the representative point stands.
15.3 The sketch pen
|sketch| is a closed primitive (SPEC 7): a pen that folds to a path. It
requires draw: (as |poly| requires points:), paints like any closed primitive
(defaults --fill / --stroke / stroke-width: 2), and derives its bbox from the
geometry.
draw: is a left-to-right list of bare calls — ordinary value-position calls, no
new value grammar beyond the :segment suffix; the value runs to its ; and may span
lines. An argument is an expression — a number, a bound value, a call, or math inside the
call’s own parens (right(w / 2), up(5 * r), SPEC 10.7).
| Call | Does |
|---|---|
move(x, y) | set the start / begin a new subpath — absolute, in the sketch’s own frame |
left(n) / right(n) / up(n) / down(n) | an orthogonal run; the verb is visual (up goes up on screen) |
line(dx, dy) | a relative straight segment |
angle(deg, n) | a run of length n at a bearing — 0 = up, clockwise (90 right, 180 down, 270 left) |
arc(dx, dy, r) | the minor arc to a relative point; r > 0 sweeps clockwise, r < 0 counter-clockwise; |r| ≥ half the chord or it errors |
arc(r, deg) | a tangent arc: continue the current heading, sweeping deg on radius r > 0 — deg > 0 turns clockwise; the heading updates by deg |
curve(dx1, dy1, dx2, dy2, dx, dy) | a relative cubic bézier |
fillet(r) / chamfer(c) | corner modifiers between two segments — a line or an arc on either side — trim both legs (chamfer cuts c back along each, by arclength on a curved leg; on a square corner, the 45° bevel) and join with a tangent arc / a straight bevel. They draw nothing alone and error anywhere but at a corner. |
circle(r) | a circle subpath centred on the current point; the point and heading are unchanged |
point() | record the pen’s current point under its attached :segment — a station; draws nothing, changes nothing |
close() | close the current subpath. A closed path is cyclic: a modifier may sit on either side of close() — fillet(3) close() rounds the corner where the last segment meets the seam, close() fillet(3) the one where the seam meets the first segment. |
Coordinates. The pen’s frame keeps the core orientation — y grows down, like
points: and translate: everywhere in Lini — but the verbs and bearings are visual,
so a profile written with up / right / arc never types a signed y; only
move(), line(), and curve() expose raw coordinates. Heading state: each drawing
call leaves the pen heading along its own direction; angle() and the tangent arc()
read and update it.
Subpaths & holes. A second move() starts a new subpath; fill is even-odd, so
an inner subpath reads as a hole — an outline with a bore is one shape, composite
parts are overlapping nodes, and no boolean operations exist or are needed. An open
path (no close(), no mirror:) is legal; fill paints it as if closed (SVG
semantics).
:segment — the point sigil in the pen
Anything the pen draws can carry a segment name, written with the point sigil (15.2) glued to its call — one rule, two readings:
| On | Names | Example |
|---|---|---|
| a drawing call | that call’s drawn segment: an edge, an arc, a bevel, a circle, a close() seam | right(50):neck, fillet(3):r1 |
point() | the pen’s current point | right(38):thread point():m1 right(32) — a station with no drawn edge |
The names are yours, not vocabulary. A :segment always glues to a call — a
floating :name is an error. point() draws nothing and changes nothing; beside a
fillet / chamfer (either order) it records the theoretical sharp corner — the
point drafting measures (the arc itself is named on the modifier). move() takes no
segment — name its landing with point() (move(-90, 0) point():origin). A
duplicate segment in one draw: is an error.
mirror: — draw half, get the whole
mirror: reflects everything the node holds — the path the pen drew and its
features — and unions the copy. The value is a list, applied left to right,
each item reflecting the union so far — two items give a 4-fold part:
| Item | Axis (through the node’s origin) | Gives |
|---|---|---|
x-axis | the horizontal axis (y = 0) | top ↔ bottom symmetry |
y-axis | the vertical axis (x = 0) | left ↔ right symmetry |
a number 45 | the line at that bearing (angle()’s convention) | angled symmetry |
What mirroring does is decided per subpath, and both intents fall out of one rule
each: an open subpath is fused — the copy joins end-to-end, the edge on the
axis the invisible seam (draw the half, get the whole); a closed subpath is
duplicated — a reflected second copy (draw one ear, get both). So leave a
half-profile open (a close() there would draw a visible spine down the axis — the
cue you meant the other form), and close a shape you want twice. A fused mirror also
generates its axis |centerline| — auto chrome,
15.7; a duplicated subpath generates none.
mirror: runs before pattern: and before placement: it builds the node’s geometry,
so anchors, dimensions, and mates all see the whole part.
A feature takes the same split, read on its position: one on the axis
reflects onto itself and is drawn once; one off it becomes a reflected second
copy — a carrier addressed and counted exactly like pattern:’s
(15.4). A reflected copy is one whose
coordinates are reflected, never a node wearing a flip: its labels read forward,
its anchors stay handedness-free, and a silhouette the renderer draws from a box (a
|slant|’s lean, a |cyl|‘s rim) rides upright with them. A node declines with
mirror: none, and its subtree with it: none means no reflection touches it,
its own axis and its ancestors’ alike. The auto default reflects iff an ancestor
does. Only the pen folds a path, so on any other primitive mirror: reflects the
features and leaves the node’s own shape; |path| and |image| read none outright
— a raw d and a raster have no reflection to take — and naming an axis on either
errors (SPEC 21).
revolve: — a turned part
revolve: x-axis (or y-axis) declares the profile a solid of revolution about
that axis through the pen origin. It folds exactly as a fused mirror: on the same
axis — draw the half, get the whole, plus the axis |centerline| — and adds the
edge lines a lathe part’s side view draws: at every profile vertex where two
segments meet with a tangent break, off the axis, a generated |shoulder| line
(geometry weight — real visible edges, SPEC 8) runs perpendicular to
the axis to the vertex’s reflected twin; a span the profile already draws whole is
skipped, and vertices sharing a station draw once, at the widest span. So a
fillet() joins tangent-continuously and generates nothing, a chamfer() keeps
two sharp vertices and generates its two lines, a step completes itself — drafting’s
rule falls out of the geometry, with no per-call cases. Edge lines live in the
sketch’s frame, so they ride break: like features. A sketch takes revolve: or
mirror:, never both; revolve: folds the profile alone, a turned part’s
features being drilled, not turned. The unary ⌀ readings require a revolved profile
(15.6).
break: — cut the boring middle
break: a b; removes the span between two stations from the view — the model
stays whole. a < b (error otherwise) are coordinates in the node’s own frame on the
break axis: the node’s longer axis by default, or named per group —
break: -40 40 y-axis; reads the stations sit on the y-axis. Several breaks are a
comma list, each group defaulting to the longer axis: break: -90 -30, 30 90;.
- The far piece slides toward the near one, leaving a sheet-space
break-gap; the cut edges draw as generated|breakline|children — the standards’ thin line with a sharp jog mid-span — styled or removed by the cascade like all chrome (15.7). - The break is a black hole for position. Everything placed in the broken node’s
frame rides the compression — its features, their sub-features, a
pattern:’s copies: a far-side hole slides with the far piece. (A descendant’s own shape never clips — only the profile cuts.) - Dimensions stay true. Anchors and extension lines land at displayed positions;
measured values always read the unbroken model — the same law as
scale:.
thread: — dress a threaded surface
thread: seg pitch; marks an authored segment as an ISO 6410 thread — comma groups
for several (thread: left 1.5, right 1.5;, a double-end stud). The segment name
reads bare — a value has no id to separate it from, the same way a chart band’s
axis: t names its axis (SPEC 14.5) — and must name a
straight run parallel to the revolve: axis, on a revolved profile. The pitch is in
drawing units, and the numbers live once — the surface gives the major ⌀,
thread: the pitch, and the chrome follows:
- the thin line —
--stroke-light— offset into the material, running the segment and stopping at an adjoiningchamfer()’s trim point. The subpath sets the sense: on an outer profile the run is the major and the line marks the minor, in by the ISO 60° depth, 0.6134 × pitch; on an inner (even-odd hole) subpath the thread is internal — the run is the drilled minor and the line marks the major, out by 0.5413 × pitch (the round view’s numbers, 15.4); - the thread-end line — geometry weight, across the full diameter — at an end where the surface continues collinearly past the run (a thread stopping mid-surface); where the profile turns instead — a chamfer, a face, a step — the geometry already ends the thread and no line is drawn;
- both doubled about the axis by the revolve.
A bare leader on a threaded segment composes its spec — bar:m20 <- reads
M20×1.5 (major ⌀ × pitch, the metric form; an internal run composes from its
major the same way) — re-cut the bar and the callout follows. An authored text
follows the composed spec, per the one-ended label law
(15.6): bar:m20 <- "LH" reads M20×1.5 LH.
On a round node — a threaded hole’s top view, a stud’s end view — thread: takes
the pitch alone (15.4).
15.4 Features, holes & patterns
A part’s features ride in its [ ] — placed at the part’s datum and rigid
with it: mate or translate the part and its holes travel along.
|rect#plate| { width: 120; height: 70 } [
|hole#pin| { width: 10; translate: -35 20; pattern: grid(2, 1, 70, 0) }
]
plate:left (-) plate.pin { side: top } // dot-path to the feature → 25
|hole| (SPEC 8) is round: width: — required — is its
diameter. It punches by paint order (fill: --bg over a filled or hatched
part reads as a through-hole, hatch-exempt with no special case) and draws its own
dash-dot centre marks, overhanging by a sheet-space constant — a hole without
marks is a plain |oval|. pin (o) reads its diameter (15.6);
pattern: prefixes the count (2× ⌀10).
thread: pitch dresses a round feature’s view with the ISO 6410 ¾ arc — a
thin (--stroke-light) circle broken over its upper-right quadrant. The type
carries the sense: on a |hole| the thread is internal — the drawn circle stays
the drilled bore and the arc sits outside it at the major ⌀ (width + 1.0825 × pitch, the ISO internal thread height); on plain round geometry (|oval|
lineage) it is external — the outline is the major and the arc sits inside at the
minor (width − 1.2269 × pitch). Centre marks are unchanged and pin (o) still
reads the drawn width. Counterbores and countersinks stay deferred
(SPEC 24).
pattern: replicates a node about its own position — a node property, legal in
any layout, though its chrome belongs to drawings:
| Form | Copies |
|---|---|
pattern: grid(cols, rows, dx, dy) | cols × rows copies at offsets (i·dx, j·dy); the seed is copy one and keeps the node’s position |
pattern: radial(count, radius) | count copies on the circle, first at bearing 0, clockwise; the node’s position is the ring centre and no copy is drawn there |
The two datums match drafting practice — you locate a grid by its first hole and a
bolt circle by its centre. The node’s bbox becomes the union of the copies; each
copy repeats the full lowering (a patterned |hole| punches and centre-marks per
copy); a radial pattern generates its |pitch-circle|
(15.7). Counts ≥ 1 (grid) / ≥ 2 (radial),
radius > 0; offsets are drawing units.
Copies are addressable by a numeric path segment — plate.bolt.2: 1-based,
grid copies row-major from the seed, radial copies clockwise from bearing 0, a
mirror:’s reflections after their originals, item by item.
The index extends the carrier’s dot-path only — copies leak no ids (bolt.2 alone
is an unknown endpoint); an index past the count errors with it
(SPEC 21). A copy is the feature at its own position: every anchor —
bbox points and authored :segments — reads that copy’s geometry; a dimension on it
measures the true model position (displayed anchors still ride break:’s
compression — 15.3); a leader lands on the displayed copy.
The bare carrier keeps its seed / ring-centre reading and its N× count prefix
(15.2, 15.6).
Composition is the geometry model — there is no CSG. A part is one |sketch|,
its surfaces and corners named where dimensions will land, or composed from
overlapping nodes in paint order: a bore in a section view is a --bg-filled
|rect| — it punches the hatch and its edges anchor a (o). The escape hatches are
core (|poly|, |path|, |image|). A parts library is plain defines — no engine
support, just bundled geometry and paint:
{
|steel::sketch| { fill: hatch(45, 6) }
|brass::sketch| { fill: hatch(-45, 4) }
}
15.5 Mates & seating
a:anchor || b:anchor seats one node against another — ||, the parallel bars of
GD&T: it moves a part — or, with a sheet-content end, an annotation (seating,
below) — and draws nothing, so it can never be confused with an annotation line.
Grammatically one more link op (SPEC 22); chains and fans parse as
usual; a mate takes no label and no markers.
nozzle:left || barrel:right // abut those faces, flush
cap || barrel // no anchors — concentric (origins coincide)
nozzle:left || barrel:right { gap: 4 } // 4 units of daylight along the normal
piston:left || bore:left { gap: -6 } // negative gap — inserted 6 deep
- Resolution. Mates resolve after datum placement, walking outward from the
ground (the first-declared child, 15.1):
each mate moves the side not yet connected to the ground, translating that whole
scope-level child, rigid, features and all.
a || bandb || aare the same mate — grounding, not operator order, decides who moves. A mate whose ends are both already grounded is over-constrained — an error naming the cycle; an unconnected island grounds its own first-declared node. Deterministic, source-ordered. - Directed vs point anchors. Sides and named edges are directed: a mate
between them aligns the faces flush along the shared normal (the other axis stays
where the datum put it —
translate:slides it), the two directions must be parallel (a:left || b:toperrors), and a named edge seats a part against an interior face (ring:right || housing:shoulder). A named edge faces the left of the pen’s travel — draw the profile with the material on the pen’s right (axis → up → across → down, the natural half) and every face points out, interior shoulders included.gap:offsets along the normal and may be negative (overlap — the one placegapgoes below zero). Point anchors (center, a freestanding name) make the points coincide — the barea || bis the origin-to-origin case — and have no normal, sogap:there errors. - Rotate, then mate; translate after. A part’s
rotate:turns its geometry first and the mate aligns the rotated anchor; the mated child’s owntranslate:applies after — the universal post-placement nudge, here a lateral slide along the face. Apin:on a mated child is ignored with a warning. - A mate between two features of one part errors — a part is rigid. Mates are
valid only where children datum-place: inside a layout-owning child the flow already
decided every position, the same over-constraint error. Dot-paths reach into parts
(
pump.shaft:right || frame:left), moving the scope-level child that contains the moving anchor.
|| with a sheet-content end is a seat. The operator generalizes to annotation
seating — same syntax, split by what the ends are:
| Ends | Reads | Who moves |
|---|---|---|
geometry || geometry | a mate | the grounding walk above |
annotation || geometry | a seat | the annotation, always — either operand order |
annotation || annotation | error | seat annotations on geometry (SPEC 21) |
- Seats run after mates, outside the grounding graph: every part is already seated when annotations place, and a seat never grounds, moves geometry, or over-constrains anything. One seat per annotation — a second errors.
- The target supplies the face: the geometry anchor must be directed — a side or a named edge; a point target errors (SPEC 21).
- A seat places; a mate aligns. The annotation’s seat anchor — its own
endpoint anchor, or the type’s default (the table) — lands on the target
anchor’s representative point, both axes: flush contact (the annotation had no
position of its own worth keeping).
gap:offsets along the target’s outward normal, positive = daylight (the mate’s signed law);rotate:turns the annotation before the seat — the rotated anchor aligns, sorotate: -90stands a symbol on a vertical face;translate:nudges after, the lateral slide along the face. - Bundles seat as one. A wrapper (a
|column|of finish symbol over frame) is sheet content like its children: it seats whole — interior laid out as usual — and reports one painted extent to the dimension packer (15.6), so rows stand off the bundle, never thread it.
| Annotation | Default seat anchor |
|---|---|
|surface-finish| | the symbol’s tip — the vee stands on the face (15.9) |
everything else — |feature-control|, |datum|, |note|, |balloon|, a bundle | the facing side — the bbox side whose outward opposes the target’s normal, read after rotate: |
15.6 Dimensions
A dimension is a link; the operator carries the kind and supplies the glyph you can’t type. The statement is the core link statement, with one relaxation: the measuring and leader ops may stand one-ended (SPEC 22).
| Write | Reads | Renders |
|---|---|---|
a:left (-) b:right | a linear span | extension lines, arrows, 25 |
a:left (-) b (-) c | a chain | each hop its own dim, one shared row |
pin (o) | a round feature | the ⌀ line across the circle — both arrows on the rims — 2× ⌀10 |
hole:top (o) | a round feature, side-anchored | the diametral line through the circle |
bore:top (o) | any node, side-anchored | the span to the opposite side, ⌀-read — ⌀16 |
body:neck (o) | a revolved-profile segment | the station’s span across the axis — ⌀28 |
body:r1 (o) | a named arc | a leader — R3 |
body:flank (<) body:base | two line-like anchors | the angle arc — 40° |
body:taper (<) | a mirrored- / revolved-profile segment | the included angle vs its own twin |
Each glyph is a picture of what it measures: the dash (-) is a length, the
circle (o) a diameter, the wedge (<) an angle. Arity disambiguates — (-) is
always binary, (o) always unary / side-anchored, (<) either.
(-) — the linear measure. The dash pictures a length: (-) spans two anchors
and reads the distance between them, projected on its axis. It is always binary —
a (-) b, or a chain a (-) b (-) c sharing one row; a unary a (-) errors (“a
linear dimension measures two anchors”, SPEC 21). Extension lines spring
from the anchors and the value rides the line (Placement & stacking, below).
(o) — the round measure. The circle pictures a diameter: (o) is unary /
side-anchored — hole (o), bore:top (o); a binary a (o) b errors (“(o)
measures one round feature”, SPEC 21). The feature picks the symbol,
per the standards: a named arc (a fillet, an arc() product) reads its radius —
R — and everything else reads as a diameter, ⌀, across whatever span its anchor
gives. Roundness is by construction (|hole| / |oval| lineage, a circle() product,
|pitch-circle|, a revolved profile), never guessed from coordinates. A bare (o)
needs an inferable axis — a round node (symmetric, any) or a revolved sketch (across
its axis, the full span); otherwise the error asks for an anchor. The ⌀ station and
full-span readings require revolve: — a merely mirrored profile’s span is a width,
not a diameter, and errors asking for the revolve (SPEC 21). R on a full circle has no auto form
(the standards say ⌀) — type a leader (pin <- "SR5"), the universal fallback for
anything auto-measure can’t read.
The diametral line. On a round node, a side anchor draws the dimension
through the circle, arrows out against the rims: :top / :bottom vertical,
:left / :right horizontal, a corner the 45° diagonal. The value sits on the line
when it fits inside; otherwise the line overruns the anchored rim and carries the
text there — hole:top (o) spills upward, packing along that ray
(Placement & stacking, below). Deterministic, no solver.
(<) — the angle. Binary, between two line-like anchors — a named edge, a
|line| / |centerline|, a bbox side: the angle between their directions, the arc
drawn at their (extended) intersection, the value riding the arc. Unary, on a named
edge of a mirrored or revolved sketch: the included angle of a taper against its
own reflection. Point anchors have no direction and error. (>) is reserved — an
error with a did-you-mean, kept for a future reading.
Auto-measure — the smart label. A dimension with no label renders its measured
value: the anchor distance projected on its axis, in drawing units, measured after
mates resolve and on the unbroken model. The number renders through
format: — the inherited presentation property
(SPEC 17): the auto default rounds to at most
2 decimals, trailing zeros trimmed — a bare number: drafting states units once, in
the title block, and a per-value suffix is format:’s job. format: shapes the
number only, never the measurement — the pieces compose around the formatted
number as count → glyph → number → label words → tol: (2× ⌀10 H7); a
fraction D stack rides the same raised / lowered machinery as tol: deviations.
The text composes from sources that each own one thing:
| Source | Owns | Example |
|---|---|---|
| the op | the glyph | (o) → ⌀ / R · (<) → ° · tol: → ± (linear (-) adds none — a plain length) |
| the geometry | the number | 10 |
| the label | the words | two-ended: replaces the number and its glyph (a (-) b "180" — the honest override for schematic or nominal figures); one-ended: follows the value (pin (o) "H7" → 2× ⌀10 H7) |
tol: | the tolerance, appended | tol: 0.1 → ±0.1 · tol: +0.2 -0.05 → stacked deviations, 0.7 × font, raised / lowered · tol: H7 → a fit class |
pattern: · mirror: | the count prefix — stacked replications multiply | 2× · a mirrored pair of holes 4× |
Axis — inference & project:. The anchors pick the axis. A directed anchor
sets it — a side name (left / right → horizontal, top / bottom → vertical) or
a named edge (a vertical shoulder → a horizontal dim across it); two directed anchors
must be parallel — a perpendicular pair has no shared normal and errors, pointing at
(<) (SPEC 21). Two point anchors read the true aligned
distance — the dim line parallel to the span, extension lines perpendicular to it.
project: horizontal | vertical | aligned overrides the point readings; against a
directed anchor it must agree — a conflict errors (SPEC 21).
Placement & stacking. A dimension sits outside the geometry, on a side: —
a horizontal dim defaults to bottom, a vertical one to right; anchors both on one
edge pull it there; side: must suit the axis. An aligned dim sits on the side
of its span facing away from the geometry centre — the bbox centre of the
scope’s geometry union; its side: left | right overrides, read along the span,
first anchor → second (left is the walker’s left). Dims sharing a side pack
into rows: each dim, in source order, takes the innermost row where its span —
text included — overlaps nothing already placed, so a chain shares one row and dims
over different stations share too. Row offsets derive from painted bounds: a
row stands clearance off everything already painted on its side — geometry, text,
callouts, frames, earlier rows — never at a fixed pitch. clearance is a
minimum, not a coordinate (SPEC 17); a
per-dim value widens that dim’s own stand-off independently, and the packer may
still go farther out to clear obstacles. A statement that leaves along a ray
instead of seating on a side — a leader’s text, a spilled diametral value — packs the
same way along its exit. translate stays the exact nudge; a dimension takes no gap:
(SPEC 21).
The anatomy is baked sheet constants (SPEC 10.5):
extension lines spring from the anchors (an edge anchor’s from its
dimension-side end — 15.2) with a small gap and overshoot past the dim
line — painted the light support tone (--stroke-light,
SPEC 10.1) unless the statement recolours, so
the geometry reads first; arrows are drafting-slender (≈ 3 : 1, filled), sized by
the dim’s stroke-width; the value rides above the line, ISO-aligned — it rotates
with the line and reads from the bottom or from the right, overridable like any text
(the styled-label form + rotate:). A span too narrow for text + arrows flips its
arrows outside the extension lines; the value stays centred inside while it still
fits there, and only a span too tight even for the bare text slides it past the
nearer one. A packed row also clears every callout’s text — leaders, angles, and
every seated or carried annotation node register as obstacles before dims
seat, each statement one painted box, a bundle’s the union of its children
(15.5, 15.9). Dimensions
are links, styled per core (SPEC 9) at the drawing scope’s link defaults
(15.1).
{ layout: drawing } // ratio 1, unit mm, density 4 — the defaults
|sketch#body| {
draw: move(-80, 0)
up(14) right(50):neck fillet(3):r1 up(8) right(60):mid fillet(3) down(8) right(50) down(14);
revolve: x-axis; // a turned part: half → whole, axis + edge lines
}
body:left (-) body:right { side: bottom } // → 160
body:neck (o) { side: left; tol: h6 } // → ⌀28 h6 — the surface, doubled about the axis
body:r1 (o) // → R3 — the fillet knows its radius
15.7 Leaders, notes & line conventions
A callout is a one-ended link, written tip-first: the glyph hugs the feature, the
line runs toward the text — which is formally the link’s label, so everything core
says about labels (the [ ] form, styling, one inline label) applies verbatim:
| Op | Tip on the feature | For |
|---|---|---|
<- | arrow | an edge or outline |
*- | dot | a leader landing within an outline — a face, a region |
>- | datum triangle | a datum feature (>- is the crow op elsewhere — the scope reinterprets it, as a sequence reinterprets ->) |
bolt <- "THRU" // arrow lands on the hole's rim
face *- "Ra 1.6" // a dot — a surface note
body:seat >- "A" // datum A on that face
bolt <- [ "R3 TYP" { translate: 30 -24 } ] // a styled / nudged text — the core form
- A callout has one tip, so the singular
marker:overrides it; the marker set gainsdatum(SPEC 7). One arrowhead style per sheet (ISO 129): a word leader’s<-tips with the same drafting-slender arrow as every dimension;*-’s dot and the datum triangle keep their own shapes. A one-ended callout with no text is an error; a one-ended->/-*errors the other way — a leader points back at its feature (a schematic scope reads that same statement shape as a label wire — SPEC 16.5). A label-terminated statement is single-hop — chain before the text (SPEC 21). - A fan shares one note.
a & b <- "2× R5"—&on a one-ended leader op keeps one text and one landing (the first endpoint steers the auto placement;side:overrides), each endpoint its own ray-cast leg, sharing what trunk the geometry permits; a leg that cannot land is an error, never a silent drop.&on a two-ended op stays the core fan of links (SPEC 9); on a measuring op or mate it errors (SPEC 21). - A datum’s letter is an identity.
body:seat >- "A"seats the letter in the standard framed box, riding the leader’s text seat at the landing — sheet-space and obstacle-registered like any callout text (15.6). Letters collect per drawing scope — a duplicate errors (SPEC 21); referenced elsewhere the letter is written bare — a|feature-control|’sdatums:validates against the set, and the|datum|node states the same identity in node form (15.9). - Text placement. The text auto-places outward: a directed feature’s
leader leaves straight off its face — along the surface normal — while a point
feature’s runs along the ray from the drawing’s datum through it; either way just
past the geometry union, horizontal — and the leader ends in a
short horizontal landing (
note-landing) before its text (note-text-gap), the drafting elbow. The stand-off is measured on the ink, not the elbow: what standsnote-offsetclear of the geometry is the statement’s whole painted block — its text, a datum’s frame, a carried annotation node (15.9) — so a leader leaving downward keeps the same air as one leaving sideways, whose text the exit ray already carried clear.side:picks the direction instead (a side or a corner); a styled label’stranslatenudges from there; the block then packs along its exit (15.6). The tip ray-casts onto the drawn outline (15.2). - The leader makes the note. A callout’s text lowers to a bare leaf — drafting
callouts are unboxed. A boxed note is the
|note|template (SPEC 8) wired with an ordinary two-ended link; a balloon is|balloon|plus a leader (b1 -* nozzle); bare"…"stays plain sheet text (“SECTION A-A”). Any other two-ended op between two nodes draws a straight annotation line, markers per the op — a flow direction, an exploded-view path.
Line & material conventions. hatch() fills section cuts
(SPEC 10.3); stroke-style: center / phantom are the drafting
dash conventions and dashed the hidden-edge one, each on its own child — one node
has one stroke style (SPEC 7). The |hidden| template
(SPEC 8) is that child ready-made — a dashed, unfilled pen profile
for interior geometry (a socket, a bore): a feature in the part’s [ ], rigid under
mates, riding break:, its :segments dimensionable like any sketch’s. Besides the section |plane| (15.8), two
chrome types carry the centerline pattern in the part frame (SPEC 8): |centerline| (a |line| — an axis, a symmetry
line, a spoke) and |pitch-circle| (an |oval|, width: its diameter — the bolt
circle; being round, bc (o) reads its PCD). A manual |pitch-circle| covers what
pattern: can’t — unequally spaced holes still share one drawn circle.
Crossing halos. Annotation linework — dimension, extension, and leader lines —
breaks where it crosses geometry: a sheet-space knockout, halo-margin wide
each side (SPEC 10.5), mask-based so the break holds
over hatching and in every theme. Never over arrowheads, text, frames, or the
contact region (a tip, a landing) — the crossing alone. The generated |halo|
chrome rule restyles or removes them scope-wide (|halo| { … }), like all chrome.
Auto chrome — one mechanism, twelve producers. The lines drafting always draws are
generated children, so the cascade styles or removes them with no dedicated knobs
(|sketch| |centerline| { stroke: none }). Removal is literal: chrome whose
stroke and fill both resolve to none puts no ink on the page, so it takes no
geometry — it draws nothing and reserves no space, its overhang included. The
same stroke: none that deletes an SVG line deletes the chrome that would have
drawn it; there is no separate opt-out:
| Producer | Generates |
|---|---|
a fused mirror: (15.3) | the axis |centerline|, overhanging the profile |
a revolve: (15.3) | the axis |centerline| + the |shoulder| edge lines at every sharp diameter change |
a thread: (15.3, 15.4) | the thin minor line + the thread-end line; on a round view, the ¾ thread arc |
pattern: radial (15.4) | the |pitch-circle| through the copies |
a |hole| | its centre-mark crosshair |
a |plane| (15.8) | its thick end strokes, the viewing-direction arrows, and the paired section letter |
a break: (15.3) | the |breakline| pair — thin, sharply jogged mid-span |
a |page| (15.8) | the sheet chrome — the |frame|, the |zone| references, the |tick| dividers and centring marks |
| annotation linework crossing geometry | its |halo| knockouts — the understroke break, above |
| a sheet’s projection link (15.8) | its straight |projection| construction line |
a |door| / |window| (15.11) | the leaf + quarter swing arc / the sill lines |
a |stairs| (15.11) | its tread lines + the up arrow |
15.8 Assemblies, views, sheets & titles
There is no |assembly| type: an assembly is a drawing whose children mate — and
drawings nest. A child |drawing| is one rigid body from outside (the core
sealed-body law): its internal mates, dims, and features stay in its [ ], its
geometry bbox is its parts’ union, and it grounds, mates, and anchors like any part.
Build sub-assemblies in isolation, then seat them — the same vocabulary at every
level; reach in where both ends are visible (motor.shaft:right || pump.rotor:left).
A project that wants the word writes |assembly::drawing| { } — a define, not a
language feature. Item balloons are |balloon| + a leader; the parts list is a core
|table| beside the drawing; auto-numbering and auto-BOM are deferred
(SPEC 24).
A multi-view sheet is ordinary layout: drawings in a |row| / |grid|, each view its
own scope and scale: (a 2 : 1 detail still dims true,
15.1). There is no |view| type and no
projection engine; views share their axes with align: origin
(SPEC 12) — a drawing’s origin is its datum, so a row of views
lines up datum-to-datum however their dimensions stack, and a grid with
align: origin; justify: origin is the first- / third-angle arrangement.
A drawing’s smart label is its title, placed below — it lowers to a
|footnote| (the bottom-centred caption template), because drafting titles sit
under the view: |drawing| "SECTION A-A"; style every title with
|drawing| |footnote| { … }. An authored label always wins; a view sourced from a
marker with of: composes one instead (Sections & details, below).
Projection construction links. The thin lines tying a feature across views are
authored correspondences, never inferred (no projection engine): in the sheet’s
scope — outside every drawing, where both views are visible — the unmarked - op
between two anchors that dot-path into different views draws one straight thin
line: side.screw:head - end.od:top. On such a link — and only there — the full
drawing anchor vocabulary (15.2) is legal outside a drawing scope,
the one exception to sealed bodies. It lowers at layout, after align: origin
and every seat have placed the views — never routed, never a packing obstacle — as
generated |projection| chrome
(15.7): |projection| { … } restyles or
removes projection lines scope-wide. Everything else stands: a marked op, a
dimension, or a mate across views errors (SPEC 21) — a construction
line relates views; it never measures or seats. View-letter arrows (of: an arrow
marker) are beyond 1.0 (SPEC 24).
Sections & details. Lini is 2D: a section’s cut face is authored — drawn with
the pen and filled with hatch(), as the bushing is (15.4)
— but a detail needs no concession, being a 2D re-render, and the engine is
re-entrant. Either way the view is a plain |drawing| { of: <marker> } — one
property, one view type. of: names a marker on the source view by id (like a
chart’s axis:); the marker’s kind decides what the view captures:
- The cutting plane —
|plane#a| "A" { at: N }, a chrome child of the view it cuts (a|line|), its smart label the section letter.at: Nplaces it: the plane runs perpendicular to an axis at stationN, the axis defaulting to the model’s longer one or named —at: 40 y-axis(break:’s convention). It lowers to the ISO plane: a thin dash-dot line (stroke-style: center) across the geometry and its overhang, thick end strokes just past each end, a viewing-direction arrow (the slender dimension arrow) at each, and the letter beside them.facing: left | right | up | downturns the arrows — defaultrightfor a vertical plane,downfor a horizontal one. The cascade styles or removes the whole marker. - The magnifier —
|magnifier#c| "C" { width: … }, ringing a region: a thin outlined circle (|oval|,--stroke-light),width:its diameter, positioned withtranslate:like any feature; its smart label the letter, set just outside the rim at 45°. An ordinary part-frame child, like a|balloon|— not generated chrome, and the single source of truth for the region it names. - The section view —
|drawing#sec| { of: a },aa|plane|. The face is authored (the hatched cut you draw);of:composes the title from the plane’s letter — doubled,A-A— plus the drafting ratio: the view’s ownscale:read directly (1:1at the default,2:1enlarged,1:1.5reduced, ≤ 2 dp — 15.1). - The detail view —
|drawing#det| { of: c },ca|magnifier|. The view takes its centre and diameter from the marker and its letter titles it (C (1:1), composed as above), so only the magnifyingscale:is yours. The engine re-lays the marker’s host view at the detail’s scale — a plain 2D re-render, no projection — keeping the geometry, dropping the source’s annotations, shifted to centre the region and clipped to the circle, with the circle drawn as its boundary — the marker’s own thin chrome paint, since the rim is the marker’s other half: both wear.lini-magnifier, so restyling the marker (|magnifier| { … }, or the instance’s block) carries the rim with it and neither inlines a thing (SPEC 18). The detail’s own[ ]annotations dimension the re-laid copies (by the ids the clones carry from the source); only the detail’s own links may reach them. A detail re-renders a base view —of:can’t name a marker inside another sourced view.
The sheet. |page| gives the multi-view story its walls: the trimmed ISO 5457
sheet as a template container, not a layout — inside its frame it is an ordinary
container (default flow; layout: / columns: / direction: free), hosting
drawings, tables, and notes as normal children in sheet space (a page is never a
drawing scope). sheet: names the trimmed size — sheet: a3,
sheet: a4 landscape — pure sugar for width / height in millimetres (the
orientation keyword swaps the pair; ISO defaults — A4 and A5 portrait, A3–A0
landscape; a bare |page| is a4), so an explicit width: / height: overrides
through the ordinary slot and a custom sheet still derives its zones. The
ANSI/ASME Y14.1 letters ride the same sugar in their own millimetres —
sheet: b (a…e; a portrait, b–e landscape) — nothing else differs. A
page’s direction defaults by orientation — landscape → row, portrait →
column — so views flow with the paper; set it to override. A page
carries no scale: of its own: the root’s density: sets pixels per millimetre
(default 4, screen-only — 15.1), a
drawing’s scale: is its drafting ratio directly, and so a default drawing on any
page draws 1 : 1 true (a 2 : 1 detail is scale: 2).
The ISO furniture is generated chrome (15.7):
the thick |frame| 10 mm in from every trimmed edge; the zone grid —
divisions of ≈ 50 mm, rounded to the nearest even count per edge (A4 4 × 6,
A3 8 × 6, A0 24 × 16) — numbered 1… left-to-right along top and bottom and
lettered A… top-to-bottom along both sides, drawn as |zone| labels and
|tick| dividers in the reference band, the margin beside the frame; and the
four centring marks, each crossing the frame at an edge’s midpoint (the middle
divider, which would coincide, is not drawn). The
content area is the frame inset by 5 mm (padding: adds to it). A
|title-block| child (ISO 7200 — a |table|, SPEC 8) is seated
by type, flush inside the frame’s bottom-right corner; content stays centred
on the sheet, and only a view whose box would run under the block lifts the
page’s flow content clear of it, as far as the content area’s top allows. String-valued field
properties — title, drawing-number, revision, date, sheet-number,
author, approved, department, reference, document-type, status — desugar
(like sheet:) into the fixed ISO grid: each a caption in the muted footer tone
over its value, and absent fields collapse their cells, so the default block is
minimal (Title / DWG No. / Rev / Sheet). The block’s smart label is its title
field — |title-block| "Socket cap screw" lowers to the same generated spanning
cell; a label or any field property selects the structured-field mode, and a
|title-block| with neither keeps the plain-table form — its cells fully authored.
In field mode, authored children remain ordinary cells after the generated ones,
in the same grid — cell: / span: honoured; an authored cell landing on a
generated field’s slot errors, naming the field (SPEC 21). There is no
logo: — a logo is an |image| in a cell (SPEC 7), or anywhere on the
page. A file whose drawn content is only pages hugs them — the paper is the
margin, so the root’s padding defaults to 0 (your own { padding: … } still
wins) and the sheet runs edge to edge of the SVG. That same predicate makes the
sheet true-scale in print (SPEC 18).
|page| { sheet: a4 } [
|drawing#side| "DIN 912 — M8 × 40" [ … ] // 1 : 1 on the sheet
|drawing#detail| "DETAIL A" { scale: 2 } [ … ] // a 2 : 1 view
|title-block| { columns: 60 auto } [
"Title" "Socket cap screw"
"Scale" "1:1"
]
]
15.9 Drafting symbols & annotation composition
GD&T rides three node templates over one shared drafting-glyph set — the
characteristic symbols, the modifier circles (Ⓜ Ⓛ Ⓕ Ⓣ Ⓟ), the finish vees —
drawn as paths like icons but sized in natural units, never fit to a box: a
glyph’s height follows the annotation font-size, its line weight the
statement’s stroke-width, so every symbol reads at dimension-linework weight
beside every value, at every view scale. All three types are sheet content
(scale: 1 — 15.1) and drawing-scope
only (SPEC 21). Place one with
translate:, or attach it three ways, all ordinary: seat it on a face with ||
(15.5), wire it with a leader (body:seat <- sf — the
same node either way), or carry it in an annotation’s [ ] (below).
|surface-finish| — the ISO 1302 surface-texture symbol. Its smart label is
the textual indication (|surface-finish| "Ra 1.6"), riding the symbol’s
long leg; symbol: picks the variant:
symbol: | Draws | Means |
|---|---|---|
basic (default) | the bare vee | any process |
machined | vee + bar | material removal required |
prohibited | vee + circle | removal prohibited |
Seated, the vee’s tip stands on the face (the type’s seat anchor,
15.5); rotate: turns it for a vertical face.
|feature-control| — the GD&T frame. The common single frame carries its
properties directly; a composite / combined frame holds |control|
children, one row each (mixing the two forms errors). The smart label names
the characteristic — the frame’s in one-row form, each |control|’s
otherwise; longhand characteristic:; setting both errors. The set is
ISO 1101’s fourteen (ASME Y14.5-2018 dropped concentricity / symmetry;
lini’s drafting lineage is ISO and both validate):
| Group | Characteristics | datums: |
|---|---|---|
| form | straightness · flatness · circularity · cylindricity | forbidden |
| profile | profile-line · profile-surface | optional |
| orientation | angularity · perpendicularity · parallelism | required |
| location | position (optional) · concentricity · symmetry (required) | — |
| runout | circular-runout · total-runout | required |
The row properties, each owning one compartment slot:
tol:— the tolerance zone width, required: a number > 0 (the deviation / fit forms are a dimension’s — 15.6).zone: diameter | spherical— the ⌀ / S⌀ zone prefix; legal only where the zone is axial —position,straightness,perpendicularity,parallelism,angularity,concentricity.material: maximum | least— Ⓜ / Ⓛ after the value; legal on the feature-of-size controls —position, the orientation three,straightness.datums: A, B maximum, C— primary → tertiary, at most three; each a bare letter declared in the scope (>-or|datum|, 15.7) with an optional per-datummaximum/least; an unknown letter errors naming the declared set.modifiers:— ordered extras after the material modifier:projected N(Ⓟ plus the projection length),free-state(Ⓕ),tangent-plane(Ⓣ).
Any combination outside these rules — the table’s forbidden / required cells,
an unknown characteristic — is an error with a correction
(SPEC 21): a frame renders semantically valid or not at all,
never plausible-looking and wrong. Adjacent |control| rows sharing one
characteristic merge its symbol compartment — the composite frame; rows
with different characteristics stack as a combined frame, in source order.
|datum| — the framed datum letter as a node: its smart label the
letter, joining the scope’s identity set exactly as >- does
(15.7 — a duplicate errors across both
forms), one frame anatomy shared with the leader’s box. Standalone it seats or
wires like any annotation; carried in a dimension’s [ ] it states the
feature-of-size axis datum — the measured feature’s axis is the datum
feature.
Annotation nodes on a dimension or leader. A drawing link’s [ ] may carry
these nodes beside its text labels: each stacks at the statement’s text
seat — under the dim value or callout lines, in source order — rides the row
like the text does, and registers its painted bounds with the packer
(15.6), so no row overlaps a carried frame. Strings keep
their label semantics (replace / follows, 15.6); a node is
never a label. Core routed links stay text-only — the grammar is
scope-blind (SPEC 22), and a node in a [ ] outside a drawing
scope errors at resolve (SPEC 21).
{ layout: drawing }
|sketch#body| { draw: …; revolve: x-axis }
body:seat >- "A" // datum A, the leader form
|surface-finish#sf| "Ra 1.6" { symbol: machined }
sf || body:top // the vee stands on the face
body:mid (o) { tol: h6 } [ // a frame rides the dimension
|feature-control| "circular-runout" { tol: 0.05; datums: A }
]
15.10 Lowering
layout: drawing resolves in the layout phase (SPEC 19) —
geometry must exist before it can be measured:
- Geometry per child, bottom-up: fold
draw:to a path (corner modifiers applied cyclically throughclose()), collect its:segments, applymirror:/revolve:(+ the edge lines andthread:dressing), expandpattern:, buildbreak:’s view map; nested drawings lower first, becoming rigid subtrees. Compute each node’s geometry bbox (stroke excluded) and paint bbox (core). - Place children: origins on the datum,
translate:applied. - Mates: walk from the ground; rotate first, seat, the child’s own translate after; flag cycles and over-constraints.
- Measure every annotation’s anchors against the seated, unbroken geometry;
compose the texts (glyph + number / label +
tol:+ count). - Annotate: assign dims to sides and pack the rows in source order; auto-place callout texts outward; ray-cast leader tips; land the elbow.
- Lower to primitives at baked coordinates: sketch →
|path|; hole →|oval|+ centre marks; the auto chrome → generated children; dim → extension|line|s + a marker-tipped dimension|line|+ text; an angle → its arc|path|+ text; leader →|line|+ marker + text; hatch → one deduplicated<defs><pattern>. - Scale geometry per the effective per-node scale; chrome stays sheet-space. Emit
geometry in source order and annotations above all of it (the drawing’s one
draw-order override, like a chart’s semantic order;
layer:still wins).
The output is an ordinary primitive subtree (SPEC 11, seam 3). The parser is scope-blind: the ops and forms parse everywhere and mean drawing only in a drawing scope — elsewhere they error at resolve (SPEC 21).
The drawing property index — owners, value shapes, defaults — is the Property Ledger; each property’s law lives in its subsection above.
15.11 Floorplan — the architectural dialect
layout: floorplan — and the |floorplan| template (|drawing|-based, so
|drawing|-scoped rules dress a floorplan too) — is the drawing engine
under another vocabulary: everything in this section applies unchanged (the
datum, scale: / unit:, anchors, the pen, pattern:, dimensions, leaders,
mates, sheets, hatch), and every “drawing-scope only” rule reads a floorplan
scope as a drawing scope — the ops, tol:, the drafting symbols, and the
SPEC 21 gates hold unchanged. What the dialect adds is a
vocabulary with its own gate: the floorplan types are legal only in a
floorplan scope — a |wall| in a plain drawing errors like any scope type
(SPEC 21) — so each drafting language keeps its own completion
surface, while every drawing-global mechanism (|sketch|, |hole|, |note|,
|page|, …) stays welcome here. No new role variables.
True-size defaults. A floorplan type’s intrinsic sizes — a wall’s
thickness:, an opening’s width:, every fixture body — are physical
millimetres, converted to drawing units through the scope’s unit: where
each is read (15.1): a bed is
1500 × 2000 mm whether the file drafts in m or mm. An authored value is
drawing units like everything else — at unit: m, a 100 mm partition reads
thickness: 0.1.
Walls. A |wall| is a |sketch| whose draw: traces the wall’s
centreline — named :segments and all — and thickness: (inherits
nearest-wins, like unit:; default 200 mm) grows it into the wall outline:
each run offset ± thickness ∕ 2, corners mitred (an acute spike bevels
at miter limit 4), an arc offset to its concentric pair (an arc radius under
thickness ∕ 2 errors), an open end butt-capped at its endpoint, a close()
seam mitred like any corner; curve() in a wall’s draw: errors
(SPEC 21). The outline is the wall’s shape: it takes the
paint — solid --stroke-dark, the poché read ({ fill: --bg; stroke: --stroke-dark } is the hollow double-line look, fill: hatch(45) a section
convention — both show their junctions, where the solid default merges by
paint order) — and it is the geometry bbox
(15.1). In
15.10 step 1 the offset runs after the draw: fold and
before the bboxes, and an opening clips it there — resolving against its
already-folded parent, the one place a child reads down from its part.
Anchors: :segments read the centreline, bbox points the outline —
and every named segment derives its two face anchors, name-in /
name-out: the segment’s own offset edges. On a closed run -in is the
enclosed side; on an open one it is the left of the pen’s travel (the
named-edge convention, 15.5). So a clear room span
— what a listing plan dimensions — is face to face,
outer:north-in (-) bedwall:top, measured like everything else; an authored
segment name ending -in / -out errors as colliding with its derived twin
(SPEC 21). |partition| is the built-in 100 mm interior define
(SPEC 8).
Openings. A |door| / |window| rides in its wall’s [ ], stationed
on a straight named segment: on: the segment, bare (the thread: shape,
15.3); at: the near jamb’s distance from the
segment’s start; width: the clear opening. The gap clips the wall
outline at the two jambs — a profile clip, not break:
(15.3): the wall keeps its length, nothing compresses,
no |breakline| draws, and each jamb closes flat across the thickness. An
opening’s own geometry is that jamb-to-jamb box — width × thickness,
seated on the segment — so an id’d opening anchors a dimension at its centre
(outer:west (-) outer.entry (-) outer:east, the location chain along a
wall — a dot-path, the sealed-body rule as everywhere, SPEC 9); it
is placed by on: / at: alone, so translate: on an opening errors.
hinge: start | end picks the jamb by the segment’s draw direction;
swing: left | right the side the leaf opens toward — left is the left of
the pen’s travel, the named-edge convention (15.5).
The chrome is generated children in the thin tone
(15.7): a door’s leaf — a line of
length width from the hinge jamb, drawn at 90° open — and its quarter
swing arc, radius width, sweeping leaf to closed; symbol: double
splits two half-width leaves + arcs mirrored about the gap’s centre;
symbol: sliding draws two overlapping half-length panel lines offset to
either face, no arc (hinge: / swing: on it error). A window draws two
sill lines across the gap at the thickness’s thirds — the double-glazing
read. An opening past its segment, on a curved one, or overlapping another
errors (SPEC 21).
Fixtures. Six symbol-bodied types — the discretes’ pattern
(SPEC 16.3), their smart label below the body like a
discrete’s value — except an |appliance|’s, which centres in its
body: the labelled-box convention ("F", "DW", "W/D"). Fixture and
opening labels stay readable like dimension text — ISO-aligned, from
the bottom or the right, never upside-down (15.6’s rule,
shared). (An opening’s is
its schedule tag beside the gap; a |floorplan|’s is the drawing title it
inherits, 15.8; a |wall|’s keeps
the sketch’s centred read.) width / height are floors as everywhere
(SPEC 5) and the body stretches to the resolved box;
symbol: picks the variant:
| Type | symbol: | Body (mm) |
|---|---|---|
|bed| | queen (default) · king · double · single | 1500 × 2000 · 1800 × 2000 · 1350 × 1900 · 900 × 2000 |
|sofa| | three (default) · two · one (the armchair) · corner · stool (the bar stool — a plain round seat) | 2200 × 900 · 1600 × 900 · 900 × 900 · 2400 × 2400 L · ⌀350 |
|dining| | six (default) · four · round | the tabletop — 1800 × 900 · 1200 × 800 · ⌀1000 — its chairs (450 × 450, drawn a small pull-back off the edge; six 3 + 3 on the long sides, four 2 + 2, round 4 at the quadrants) extending the bbox |
|bath| | tub (default) · shower · toilet · sink · double-sink (one unit, two square basins — the kitchen run’s) | 1700 × 750 · 900 × 900 · 700 × 400 · 500 × 400 · 800 × 450 |
|appliance| | stove (default) · fridge · washer · dishwasher | 600 × 600 each |
|stairs| | — (steps: N required, ≥ 2) | 900 wide × N × 250 run; treads across the flight, the up arrow from the first tread past the last |
A counter, island, desk, or coffee table is a plain |rect|; anything else is
a |sketch| define — the parts-library escape
(15.4). A room name is plain sheet text
("KITCHEN", an authored area beside it); computed room areas, curved-segment
openings, and a north arrow are deferred (SPEC 24).
16. Schematic
A schematic reads a diagram as a circuit sheet: layout: schematic
places components and lets the orthogonal router draw the wires —
unlike sequence and drawing, the engine never consumes its links
(SPEC 11); it places, reinterprets a few link forms,
and dresses the result. Wires land on pins — fixed ports the router hits
exactly (ROUTING.md, Fixed ports) — bend square, and meet at
junction dots. Everything else is the core: a named wire is a link label,
the region boxes are |group|s, the sheet is a |page|, the title block ISO
7200 (SPEC 15.8). Its children split
by role:
| Child | Is | Drawn |
|---|---|---|
a 3+-pin part (|component|, |opamp|, |J|, |Q|), or anything explicitly placed (cell:) | an anchor | on the scope’s track grid |
a |label|, or an unplaced 1–2-pin part | a satellite | seated at the pin its wire touches |
a link (a - b) | a wire | routed orthogonally, square-cornered, junction-dotted |
a one-ended link with text or a capsule (U7.DIAG - "NSTDBY", c24.p2 - |gnd|) | a label wire | a lead to a seated |label| — a run of trace under a plain net name, a stub to a tag or a symbol (16.4) |
a pin: child (a note, a legend) | sheet chrome | an overlay flush on the finished sheet, in neither the tracks nor the seats (SPEC 5) |
Vocabulary: a component is the part instance; a symbol is the drawing
it (or a label) wears — symbol: names one, exactly as on |icon|
(SPEC 7). Schematic types are legal only in a schematic scope
(SPEC 21); the schematic link laws (16.5)
reach links written in nested ordinary containers, but placement never
cascades — a nested |row| or |grid| places its own children, exactly as
in a drawing.
16.1 Placement — the lattice
The sheet is a grid. A scope places on two pitches. The fine pitch is
pin-pitch (SPEC 10.5) — every pin, stub tip and
wire track lands on it. The coarse pitch is the scope’s gap, which every
part centre lands on: here gap is the column and row pitch, not the space
between two tracks, and it rounds up to a whole number of fine pitches, so a
part centre is always a wire line too (gap: 120 80 states row and column
apart, as gap reads everywhere). Two adjacent anchors with nothing between
them stand one coarse column apart, and never closer. Two wired neighbouring pins stand one
fine pitch apart, so a scope whose clearance runs past that pitch is an
error (SPEC 21) — said once, where the number is written, rather
than as a stray for every lead that then finds no track. The finished sheet
centres on the scope’s own origin, and that shift is a whole number of fine
pitches — so a part placed on the lattice is still on it once the sheet is
squared up. The lattice is the scope’s, counted from its own origin, and
the router rounds a wire to it wherever the parent seated the scope
(ROUTING.md, Track quantum) — a nested sheet stands exactly where
its parent put it.
Ink never places. A satellite’s cell comes from the lattice, never from the
width of its ref or value: a long value overhangs the column beside it, and
gap is the lever. Ink is read only where a count of cells is owed — a
field’s origin, the air two neighbouring anchors keep, a label symbol’s reach
across the line it stands on — and every such reading rounds onto the lattice.
So a part’s text can hold a field or a track one cell wider, and never says
which cell another part takes.
A satellite seats by its connection geometry, never by its drawn box: the centre of the terminals a part carries, the one connection point a label is, the whole run of trace a net run draws (16.4). A symmetric two-terminal symbol’s port midpoint is its box centre, so nothing moves; a flag drawing its name beside its symbol stands the symbol on the lattice line and lets the text hang off, which is what a sheet draws. Seating such a part by its box would put its wire half a name off the line the wire belongs on, which is ink deciding where a wire goes.
Anchors ride tracks. Anchors take the scope’s track grid: one row by
default, in declaration order; columns: N wraps; cell: c r places
explicitly. Track indices are ordinal — tracks spring into existence up to
the largest referenced index and empty tracks collapse entirely, so sparse
indices (10, 20, 30…) are safe ordering room and never inject invisible space.
This is the engine’s own track list; it does not alter the grid layout’s laws
(SPEC 12). A track sizes on the fine grid, to
what stands between its anchors and the next track’s: the earlier’s field on
the side they face, a span’s cells between them, the later’s field, and the
corridor the two keep — a fine pitch of air per wire crossing it, one at
the least, so a sheet with more traces between two parts spaces them further —
never closer than a pitch of air between their bodies, nor than one coarse
cell centre to centre. Satellites consume room, never tracks.
A chain is a walk. A satellite chain — the run of satellites one wire holds — takes a ray, a lane, and a slot per member:
- its ray is the growth direction, and the chain’s own members state
it: every member presents the terminal its wire enters back up the ray, so
the first member from the pin whose facing is already fixed decides — a part
turned by an explicit
rotate:, else the terminator’s own drawing (a|gnd|’s connection point sits at its top, so its chain grows down; a power flag’s at its bottom, so up). With nothing stated the chain runs straight out along the pin’s normal. A ray anti-parallel to that normal yields to it, the terminator posing inverted as a sheet flips a ground above a part. A pin’s straight corridor belongs to the wire running through it — one on to another placed part — so a chain sharing such a pin turns onto the canonical ray: down off a side pin, rightward off a top or bottom one. A chain of bare net runs is exempt, being that very trace named (16.4). - its lane is the cross coordinate: a line out from the anchor’s ink for a chain that turned off its pin, the pin’s own line for one that grew straight out and takes no lane at all.
- its slots carry the members: the first on the field origin, and each one after it on the first line past the one before. Centred, not hung — so a cap and a resistor off one bus share a body row, their leads differing by their own lengths.
The trunk is the walk from the pin to the terminator; a subtree hanging off a mid-trunk member is a branch, grown from its attachment as a sub-chain along the ray its own members state. A tap — a single symbol-label branch, the rail flag beside a junction — takes no slot, hanging off its attachment member along its own drawn convention and stepping aside when that points back into the trunk.
A cell is its content’s, and nothing steps by gap. A member’s cell is the
fine bands — a pitch centred on each fine line — its drawing reaches into
from its seat point, so it lies where the drawing lies: a ground’s hangs below
its connection point, a flag’s reaches further on its name’s side. A part
adds the rows its ref and value take across its ray, where the readout rule
stands them (16.2) — a corridor part whose pair stepped
whole to one side leaves the other side’s row to the next pin — except that a
pair standing beside its body takes the coarse cell centred on the
part’s axis: text width never places, that room is the one thing gap states,
and it is the one rhythm a column of parts keeps. Along its ray a part takes its
symbol’s length and a fine pitch of air; a label, being its own terminal and
no part (16.4), takes only what it draws, either way. So a
no-connect cross grown out of one pin leaves the pins either side their own
rows, a net run takes exactly the line it lands on, and two bare returns off one
connector stand a fine pitch apart where two resistors stand a whole column. A chain’s cells run from its own pin’s line out to its
outermost member, which is the column it really draws: a wire’s width (one
fine pitch) from the pin’s line to the first cell, the cells from there.
Every line the field picks is then found by stepping one fine line at a time until the cells clear, and never by a pitch stated in advance: a lane is free when no cell of the chain meets one already committed and its wire — one pitch wide, down its line to its last member — runs through no ink a seated member paints, and a taken lane steps out a fine line and tries again; a member stands on the first fine line past the one before it. So a value overhanging its coarse cell pushes no part, only the wire that would otherwise cross it. That is why two stacked discretes still stand a coarse pitch apart — their cells say so, and a wire between two bodies any closer has no track — while a ground ends a chain a step under the part above it. A chain’s lead — the run from its pin out to its lane — reserves nothing, the lane order keeping it clear. So four things need no rule: an up-chain and a down-chain off one pin share a lane, their columns meeting at exactly the one line; two pins whose rays point at each other take a lane each, both claiming the band between them; a second chain claiming a pin’s straight corridor steps beside it, theirs being the same cell; and no chain lands where a lead must cross.
Lane order is the pins’ own. A pin’s column is live above its row where a chain climbs off it and below where one drops, and a lead crosses every inner column live toward its pin — so the lanes go innermost first to the pin whose column the fewest other leads of the side would cross, a pin’s up- and down-chain allotted together, ties to the pin deeper along the canonical direction (down, right), then statement order. On a one-way side that is depth along the ray; a side carrying both rays crosses only where an upper pin’s return must drop past a lower pin’s rail, on the rail’s lead. The chains that grew straight out take no lane and compete for none, so they stand first: they are the geography every lane then steps past — except a part-led one sharing its pin with a turned chain, which grows after that pin’s lanes and past them, the junction on its own lead ahead of its first member. A chain led by a bare net run is exempt, being that very trace named. Chains off one pin take their lanes in their parts’ declaration order — bar a flag, a turned chain of one symbol label, which rides the outermost lane the pin’s other chains took where its cell fits there, so the rail closes over the whole run it feeds rather than ending between two of its columns.
A field starts where its cells clear the ink. The innermost lane on a side is the first line whose cells stand clear of the anchor’s own drawn ink there, readouts included — so a part’s cell edge lands on the body and no further out, and a chain carrying nothing but a ground symbol starts a fine pitch off it. The first slot clears what that chain’s own lead passes: a chain that turned into a lane is beside the body already — that is what the lane is — and clears the deepest wired pin of its side along the ray, so a lead crossing its column meets bare wire, never a body; one that grew straight out has its ray pointing through the body, and clears the ink. Every one of these is a separation, measured in ink and quantised to the fine grid: rounding a separation up to a coarse line buys a whole cell of bare wire for a shortfall of one unit.
A slot origin is the track line’s, not the anchor’s: every anchor riding the line across the ray — the same track row for an up or down ray, the same track column for a left or right one — shares one, the deepest requirement among them. That is what stands two anchors’ fields on one row, bar the alignment below, which carries an anchor and its whole field together.
Terminators end where they end. There is no ground row and no flag row. A chain’s last member stands a step under the one above it like any other, and chains of one depth therefore land on one line — which is the ground line a sheet draws when it draws one, and none when the chains differ, as every reference sheet has it. Aligning three power flags naming three nets would say nothing at all.
Facing pins align. Two anchors in one track row stand centre to centre on a shared row line, except that a wire — or a span, whose members all ride one line — joining a facing pin pair (an earlier column’s right pins against a later column’s left pins; columns mirror it) aligns that pair instead, and the wire draws dead straight. The shift is a whole number of fine pitches, so alignment never breaks the lattice, and it is struck before the tracks size, so an aligned anchor never overruns its allotment. Deterministic: anchors take alignment in track order, each through the first statement-order wire reaching a placed neighbour.
Two placed ends. A chain held at two different anchors is a span: its
members ride the wire’s landing leg — the straight run into the second end,
on that pin’s own line — on consecutive coarse cells, the last-named nearest
that end; the region between the two tracks holds the earlier anchor’s right
lanes, then the span, then the later’s left lanes. A chain whose ends are two
terminals of one anchor is a bridge (U2.EN - R5 - U2.VIN): it grows off
the first-named pin like any one-end chain, whichever sides the two pins take,
and the far wire is the router’s, merged into the second pin’s net at a junction
dot. Pins on one side make that wire’s way home the second pin’s own row.
A chain it taps mid-way — the far wire landing on a later member’s entry pin —
straddles that row where the members before the junction fit above it: they
take the chain’s own origin, since a wire ending on the chain is no lead it must
clear, the tapped member stands past the row, and the tap comes home dead
straight to the junction leg (the feedback divider, tapped between its two
resistors); with no such room the chain clears the row like any lead. A member
lying along the row instead steps its ref and value clear of it rather than
straddling (16.2): left on it they wall the return off,
and a wire that must land on a port and cannot tee into its net can then only
orbit the member’s body. A chain distributes between
two ends and no more, so a third placed end is named and dropped; a chain
with no placed end falls back to the flow. Both warn (SPEC 21).
A seated satellite registers as a router obstacle like any node. cell:
promotes a satellite to an anchor; translate: nudges it from its seat
(pin-relative — move the component and the nudge travels along,
SPEC 5).
Pose is rotation. Every schematic part has authored connection geometry
— pins on parts, one connection point on a label symbol. A satellite
auto-poses to the 90°-step pose that presents its terminal back up the
chain’s own growth ray (deterministic tie-break: the unrotated pose, then
clockwise) — so a ground, which sets that ray from its
own drawing, is never turned, and a part in the middle of the chain stands to
meet it. An explicit rotate: 0 | 90 | 180 | 270 forces the pose,
and so states the ray for the chain it stands in (above): a resistor stood with
its entry pin at its bottom grows its chain up, the unforced members turning
to follow. mirror: x-axis | y-axis flips the part about its own axis
first, the turn coming after — the axes named as the pen names them
(15.3): y-axis swaps left and right, so a header faces
the other way with pin 1 still on top, and a transistor swaps the side its
collector points to. A flip is never chosen for a part — the chooser turns it,
the flip riding along. Either is read at lowering — pins re-side, the symbol
re-lays, and every text (ref, value, pin names) stays upright, bar a net name,
which reads along its run (16.4) — never as a paint transform;
any other angle or axis is an error (SPEC 21).
16.2 Components & pins
|component#U7| "TMC2300-LA-T" [
|pin#VS| { number: 18 }; |pin#STEP| { number: 4 } // auto — the bilateral split
|pin#nstdby| "VIO/NSTDBY" { side: right; number: 11 }
]
U7.VS - c24.p1 "VM"
|component| is the generic pin-bearing box — an IC, a module, a relay. Its
smart label is the part name / value; its [ ] holds |pin| children.
Pins without a side: split bilaterally — the first ⌈n/2⌉ on the left,
the rest on the right, declaration order top-to-bottom (the ⌈n/2⌉ split of
SPEC 12’s bilateral tree, mirrored — a component reads
left-to-right); side: left | right | top | bottom
overrides, and explicitly-sided pins are excluded from the split count. Pins
lower into generated anonymous side rails — scope-transparent
(SPEC 9), so U7.VS resolves with no rail in the path —
one pin-pitch apart along the rail they landed on.
A pin’s smart label is its name, displayed inside the body; with no
label the pin’s id is displayed — schematic identity is drawn, the way a
|hole| draws its centre marks — and the label form covers names that can’t
be ids ("VIO/NSTDBY", "1.8VOUT"). number: draws the pin number outside,
beside the stub — the short lead the pin extends outward; the wire lands
on the stub tip, departing outward along the pin’s side. Pin anatomy — stub,
name, number — folds into the component’s own routing obstacle, and a
pin’s translate: slides it along its side — a cross-axis component is an
error (a pin lives on its side). A |space| among the pins is one empty
rail slot — a fine pitch of air, the gap a datasheet draws between pin groups —
on the rail of the pin written before it (a leading one, of the pin after
it), or of its own side:; span: N makes it N slots. It is no pin, so
the split never counts it, though the odd-slot rule below does. A single-pin
component is legal
(a test point, a mounting pad); an unwired part needs no id at all. |pin|
the type and pin: the out-of-flow property (SPEC 5) are
one word in two roles — never ambiguous: a type lives in bars, a property
before a :.
The id is the reference designator. A component or discrete displays its
id verbatim (#U7 reads “U7”). An anonymous part mints a display
ref — prefix from its type (|R| → R1, R2…, IEEE 315), prefix:
overriding (|ic::component| { prefix: "IC" } mints IC1…), declaration
order, skipping authored names. A minted ref is display-only, never an
endpoint — wiring R1.p1 to a minted ref is an unknown endpoint
(SPEC 21): don’t care → free numbering; wire it → name it.
Ref/value text places by rule, never by search
(SPEC 16.1): above a component, the ref over the
value, the pair clearing the top rail — stubs and numbers — where pins
landed there. A symbol-bodied part turned to stand across its row stands on
its own wire, so its pair steps beside the axis and reads its side off the field
it is seated in — outward, away from the anchor (right aligned in a left
field, left aligned in a right one, and right on neither flank); one lying
along its row wears them above and below, centred — except where the
anchor’s pins on that side crowd one way and leave the other free, when both
lines step whole to the free side, the leading one keeping the line it had. A
readout line stands further off a body than the one fine pitch the next
pin’s row sits at, so a pair left straddling draws over a live row and closes
the corridor the wire that needs it runs in
(16.1’s bridge).
A component’s pin rails seat so its pins land on fine lattice
lines, whatever their count: a rail of an even count straddles its own
middle, so it reserves the odd slot it is short of, and a part carrying only one
of the two horizontal rails reserves the other, so its side pins keep the
origin’s line. The outline then re-centres on the pins, so a reserved slot shows
at neither end and the origin — the lattice point the pins count from — may sit
half a pitch off the box’s middle. A pin off the lattice cannot be aligned to a
neighbour’s (16.1), and the wire between them
jogs.
|J| is the connector — a |component| define, prefix J, whose pins
show numbers only; pins: N generates N numbered, nameless pins
(|J#J3| "JST S4B-ZR" { pins: 4 }). A connector is one column, never the
bilateral split: the generated pins are minted side: left, the header or
terminal block standing at the sheet’s edge with its pins facing the circuit.
rotate: 180 turns the column the other way (16.1),
which is what a part on the left edge wants, and mirror: y-axis does the same
with pin 1 still on top; authored |pin| children keep whatever side they
state. |opamp| is the amplifier triangle —
prefix U, pins out, inp, inn, its power pins present but hidden by
default.
16.3 Discretes
One-, two- and three-terminal parts drawn as symbols (IEC), each with
generated pins — so c24.p1 works with zero authoring — and its ref
family as its type name:
| Type | Mints | Pins | symbol: variants |
|---|---|---|---|
|R| | R1… | p1 p2 / p1 p2 w | pot (the wiper is w) · ntc |
|C| | C1… | p1 p2 | polarized |
|L| | L1… | p1 p2 | — |
|D| | D1… | a k | zener · tvs · schottky |
|LED| | LED1… | a k | — |
|Q| | Q1… | b c e / g d s | npn (default) · pnp · nfet · pfet |
|Y| | Y1… | p1 p2 | — |
|F| | F1… | p1 p2 | — |
|FB| | FB1… | p1 p2 | — |
|SW| | SW1… | p1 p2 | toggle (default) · push |
|BT| | BT1… | plus minus | cell (default) · battery |
|V| / |I| | V1… / I1… | plus minus | dc (default) · ac |
|M| | M1… | p1 p2 | — |
|BZ| | BZ1… | p1 p2 | — |
|TP| | TP1… | p1 | — |
The smart label is the value (|R#R18| "470m"); symbol: picks the
variant — one knob for every family, and it sets the pin ids where they are
semantic (d3.a, q1.b, q1.g per variant, bt1.plus). Polarity in a
wire is a pin path (vm - |D|.k - x — cathode first). Orientation is
rotate: (16.1).
A two-terminal symbol’s two leads are one line, and that line is the symbol’s own centre line: a drawing that lies to one side of it — an inductor’s coil, a polarized cap’s plus mark, a switch’s blade — reserves the side it does not use, exactly as an even pin rail reserves the slot it is short of (16.2). A part’s cell is centred on its lattice point, and so is the pair naming it, so a lead off that centre would hang both to one side of the wire they belong to. A three-terminal symbol’s two same-side pins straddle that line by a whole fine pitch each, so they are lattice lines too, and whatever rides one of them (16.1’s span, a facing pin aligned to it) lands on the grid exactly as it would on a component’s rail.
A symbol’s pin-to-pin length is no whole number of fine pitches, and at the
default gap it cannot be: two parts stacked one coarse pitch apart leave
gap − length between their bodies, and the router wants a clearance either
side of the wire joining them, so the length falls short of the pitch it would
need. Nothing asks it to be whole. A two-terminal part’s wire leaves along
its own axis and turns on the line its centre stands on — a coarse line, and
so a fine one — which is the line the track runs on; the pin’s own coordinate
along that axis is where a wire ends, never a track it travels.
16.4 Labels
Components have pins; a label is its own terminal. |label| is the net
tag: its smart label is the net text, drawn in the tag outline;
shape: picks the outline — plain (default, no outline at all) ·
left · right · both (a flag, one or both ends drawn to a point) ·
round (a stadium) — the shapes are visual, not semantic (the
conventional readings — output, input, bidirectional — are the reader’s, as a
sequence’s -> vs --> are); symbol: swaps in a drawing from the
schematic symbol set — gnd · earth · chassis · power · nc ·
antenna — text beside it like an icon’s, never under it: the symbol’s own
edge is the label’s connection point, and the wire arrives there. Text alone
is a net label, a symbol alone a ground, symbol + text a power flag. |gnd|
and |nc| ship as built-in
defines; a power net is a one-line define with intrinsic text
(SPEC 8):
{ layout: schematic; |vm::label| { symbol: power } [ "VM" ] }
c24.p2 - |gnd|
U7.VS - |vm|
A label has no pins and no dot-path; a wire lands on its connection
point — a fixed port like a pin’s. :side is an error on every terminal,
pin or label — a terminal owns its connection geometry.
A plain label is a run, not a stop. The two shaped readings are bodies
the wire ends on — an outlined tag, a symbol — but a sheet writes a bare net
name beside a stretch of trace, and shape: plain with no symbol: draws
exactly that: the label’s box is a run of wire, its connection point the
end away from the pin, so the router draws one wire the whole length of it
and the name ends up over a trace. The run is net-label-run long
(SPEC 10.5) and grows for a longer name — the
ordinary width floor (SPEC 5), so |label| { width: N }
raises it. Being a run and not a body, it is no obstacle: its frame is that
landing line alone, and its text obstructs nothing, exactly as a link label
does not (SPEC 9).
A name on a wired pin rides the wire. A text label wire (U8.BRA - "RS_A") on a pin another statement wires (U8.BRA - R20 - |gnd|) mints no
run: its text becomes that wire’s net-name label — the two-ended spelling
U8.BRA - R20 "RS_A" (16.5) — on the hop touching the pin of the
first other statement naming it, its side: riding along. A shaped tag
still draws its body; a declared |label| stays a run.
A no-connect cross is a mark, not a member. |nc| stands with its
connection point on the first fine line at least one fine pitch past the stub
tip, and shares no slot row (16.1).
Net text stands off its wire, never on it. The name sits a constant
net-label-offset clear of the centreline — a schematic wire is never cut
(16.5). Which side:
| Run | Text |
|---|---|
| horizontal | above |
| vertical | beside, reading along it bottom to top — ISO-aligned, as a dimension’s value rides its line (15.6) — on the freer side: a minted run reads its field and steps outward, away from the anchor it hangs off (SPEC 16.1), a wire’s own label the room the scene leaves either way; ties break on the routing side rank (right → bottom → left → top) |
side: left \| right \| top \| bottom forces it — on the |label| for the
minted run, on the wire statement for the two-ended form (u7.vs - c24.p1 "VM" { side: bottom }), one more owner of the side homonym
(SPEC 17). A run poses like any part
(16.1), read at lowering; its name is the one
text a sheet turns, a name belonging to its trace as a label does to its body.
16.5 Wires
A schematic wire is an ordinary link, routed by the orthogonal router
(ROUTING.md) with the scope’s dress: ends land on fixed
ports (stub tips, label connection points), corners bend square
(corner-radius: 0, the scope’s link default — SPEC 17),
and a junction dot — generated |junction| chrome — marks every point
where three or more wire ends meet (a fan’s trunk split, a shared pin).
A plain net run’s lead never counts — the run’s box is the trace it
names (16.4), so that wire is the one being named, not a
second conductor leaving the point; every other terminal’s lead counts, so a
rail forking to its power flag and its decoupling cap is dotted where it
forks. Crossings stay clean and dotless. The wire laws:
- Pinless landing gates on arity, never on a type list: a wire to a
1-pin part lands on it; to a 2-pin part, on the next free pin in the
type’s pin order (both taken → an error naming one); to a 3+-pin part it
is an error suggesting a pin. Dangling pins are legal —
|R| -> alandsp1,p2stays open. - A chain passes through a 2-pin part: the named (or next-free) pin is
the entry, the other pin the exit —
vm - |R| - |LED| - |gnd|is a series circuit in one line;vm - |D|.k - xenters at the cathode and exits at the anode. This law is the one carve-out from the chain equivalence (SPEC 9, SPEC 19): two statements naming one pin are a junction there, not a pass, so a schematic chain lowers cut only where the pass-through resolved (both pins written down) and one whose landing the scope cannot see stays a chain — only the chain itself still says what it means. - Duplicates error — a repeated endpoint pair means nothing on a sheet; and same-pin landings merge into one implicit fan at the shared port, drawn as one lead until the split, dotted there.
- No implicit auto-create. A bare unknown id never mints a box in a
schematic scope — the error suggests the quoted form: `did you mean
- “NSTDBY” (a net label)?`. Declaration-at-first-use is the typed capsule (SPEC 9).
- A label wire is the one-ended form — the drawing-leader statement
shape (SPEC 15.7) read by this
scope:
U7.DIAG - "NSTDBY"mints a|label|seated at the pin; the op’s end marker sets the label’sshape:(-plain,->right,-<left,-<>both,-*round) exactly as an operator’s line setsstroke-style(SPEC 9); an explicitshape:wins. A capsule terminator (- |gnd|) is the symbol form of the same statement. A plain one on a pin another statement wires mints nothing: its text becomes that wire’s net-name label (16.4). - Markers shape labels, nothing else: an op’s marker is legal only on a
wire ending in a text-form label — a marked part-to-part wire, or a
marker at a symbol-form label, errors. The op’s line stays free
(
--is a dashed wire, plainstroke-style). - A two-ended wire’s net name is its link label —
U7.VS - c24.p1 "VM"— placed byalong:as everywhere, then given the net-label convention whole (16.4): stood clear of the trace and inked--lini-label-ink, since a sheet carries no wire text but net names. Both spellings of one name therefore read alike. A shaped tag on such a wire is a separate label-wire statement at one of its pins. - A sheet never opens a trace. The label knockout — a label riding its wire, the wire masked open behind it — is the diagram convention (SPEC 9); a schematic scope draws in the other one, standing the net name beside the line, so there is nothing to cut around. The law is the scope’s, not the placement’s: should a name ever fail to clear its wire, it overlaps a whole trace, which still reads, rather than punching a hole in one, which is a wrong drawing.
16.6 Look
The classic sheet is the default inside the scope, riding role variables
(SPEC 10) so a theme retunes it: wires
green, part bodies pale yellow with dark-red outlines, labels teal, pin
numbers muted, the scene beige — each a light-dark() pair. The scope’s
generated link defaults (SPEC 17): a thinner
wire, the net tag’s ink, corner-radius: 0.
Opting into the engine is one decision. layout: schematic carries the
scope’s own config — the lattice gap and that tighter clearance — wherever
it is written: the sheet’s baked constants (SPEC 10.5)
are tuned to it, so a scope routing at the diagram’s default would stray the
leads it seats. |schematic| is the template (|block| + the layout, plus
the sheet wash); a root { layout: schematic } works like every root engine;
and |region::group| { layout: schematic } is a captioned block that seats
its own parts, keeping its own paint — its label a |sheet-caption|
(SPEC 8), inside the frame as a sheet titles a block. Groups,
pages, and title blocks are the core types, restyled by scoped rules.
16.7 Lowering
layout: schematic resolves in the layout phase
(SPEC 19): desugar has already lowered components
into rails and chrome, minted label wires and capsule declarations, and
emitted the scoped look rules; the engine then assigns every satellite its
cell (ray, lane, slot), packs the tracks on the fine grid,
absolutizes the seats, seats the readouts, and hands
every wire — with its fixed ports — to the router. Junction dots are read off
the routed geometry and emitted as |junction| chrome. The scope’s links stay
ordinary routed links and its children arrange in place — no subtree is
consumed; only the generated chrome (pin rails, readouts, tags, junctions) is
new.
Canonical, dense lookup. The narrative (Parts I–II) teaches once; this part is the authoritative tables — every property, the output, the pipeline, the grammar, the errors — and never repeats the prose.
17. Property Ledger & Support
Every property is name: value; — dash-case, positional, space-separated values
(SPEC 3). This section is the one place that answers which
property works where.
A property applies everywhere by default; the exceptions are marked. An exception is
always one of two kinds: type-owned — a property a primitive requires or reads
(points on |line|, symbol on |icon|, skew on |slant|) — or layout-owned — a
property an engine interprets (cell on a grid, place on a sequence note, data on a
chart).
Validation is strict where the wearer is known, lenient where a class is polymorphic (messages in SPEC 21):
- An unknown property name is an error, everywhere — even in a class rule; no owner accepts it. The message suggests the nearest name.
- A known property misused where its wearer is statically known — an instance’s
own block, an element rule (
|box| { },|-| { }), an id rule, a descendant rule’s tail, the root block — is an error with a contextual correction:pointson a|box|,cell:off a grid, a box property on bare text, a layout’s own surface used outside it (SPEC 21). A layout-owned property errors where its owning layout is statically known to be absent, and is inert otherwise. - In a
.classrule the CSS semantics hold: a property is inert on wearers that can’t use it; it warns only when it is dead for every wearer, and a defined class no node wears warns too. - A malformed value (wrong arity, out of range) is an error, wearer-independent.
State marks used below: ✓ built and honoured · ⌛ meaningful but not built, a candidate (SPEC 24) · — not applicable.
The container × layout matrix
The high-signal grid: which container / layout property each engine honours. (Paint, text, and box-model properties are universal to every node — the tables that follow.)
| Property | flow | grid | tree | sequence | chart | pie | drawing | schematic |
|---|---|---|---|---|---|---|---|---|
direction | ✓ row/column | — | ✓ +bilateral | — | ✓ +radial | — | — | — |
gap | ✓ spacing | ✓ spacing | ✓ generation × sibling | ✓ pitch / spacing | ✓ plot gutter | ✓ plot gutter | — (a mate reads its own — SPEC 15.5) | ✓ the coarse lattice — column and row pitch (SPEC 16.1) |
gap-fill | ✓ | ✓ | — | ✓ᵇ | — | — | — | — |
padding | ✓ | ✓ | ✓ | ✓ᵇ | — | — | ✓ frames the sheet | ✓ |
align / justify | ✓ | ✓ per-column | — | ✓ᵇ | — | — | — | — |
width / height | ✓ (slack) | ✓ (slack) | ✓ a floor | ✓ (surplus distributed) | ✓ box size | ✓ box size | ✓ a floor | ✓ a floor |
columns / rows / cell / span | — | ✓ | — | — | — | — | — | ✓ columns + ordinal cell (SPEC 16.1) |
clearance (scene config — cascades, SPEC 9) | ✓ its links | ✓ its links | ✓ its links | — (messages route straight) | ✓ text off what it labels | ✓ | ✓ a dimension’s stand-off | ✓ its wires |
container paint (fill stroke radius shadow opacity href) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
✓ᵇ — honoured on the participant / frame boxes’ own content (they are ordinary
boxes), but not by the sequence engine’s placement of them on the time axis
(SPEC 11). A chart / pie consumes its children into marks, so that
case does not arise — hence —.
A floorplan reads the drawing column — the same engine
(SPEC 15.11). A stack reads it too,
minus the drafting rows: the placement is the one a drawing is built on
(SPEC 12), so the arranger knobs are — and padding,
the sizing pair and the container’s own paint are honoured.
Universal properties
Honoured on every drawn node, in every layout (a box; text takes the marked subset).
Paint & stroke (SPEC 6, colour SPEC 10):
| Property | Value | Default |
|---|---|---|
fill | colour · none · gradient · auto | --fill (box) · none (block/line) · --icon-fill (icon) · currentColor (text) · none (root — the scene background, SPEC 18) |
color | colour | inherits (--text-color) — text colour for the subtree |
opacity | 0..1 | 1 |
stroke | colour · none · gradient | --stroke (--group-stroke on group) |
stroke-width | number | 2 (|group| and a sequence frame: 1) |
stroke-style | solid·dashed·dotted·wavy·center·phantom | solid — wavy link-only by design; center / phantom on shapes and |line|s (SPEC 15.7) |
radius | number | 0 (block/rect) · 8 (box/group) — rect + polyline join; on a hex / diamond / slant / poly an error ⌛ |
shadow | N · dx dy · dx dy blur · dx dy blur color | off — tint --shadow-color |
Text — all inherit (SPEC 6); text-valid on a bare string:
| Property | Value | Default | Kind |
|---|---|---|---|
font-family | ident · string · --var | --font-family | live |
font-size | number | 15 — chrome derives from it: a link label 11∕15, a caption 12∕15 (SPEC 6) | baked |
font-weight | normal·medium·semibold·bold·400·500·600·700 | medium (500, --font-weight) | live — measured at the resolved weight (SPEC 6); another number is an error, arbitrary 100–900 ⌛ |
font-style | normal · italic · oblique | normal | live |
text-transform | uppercase · lowercase · capitalize · none | none | baked — into the text, measured as drawn (SPEC 6) |
text-decoration | underline · overline · line-through · none | none | live |
text-shadow | dx dy blur colour | — | live (numbers gain px) |
letter-spacing | number | 0 | baked |
line-spacing | number | 0 | baked |
Box model & placement (SPEC 5):
| Property | Value | Default | Notes |
|---|---|---|---|
width · height | number · auto | auto | border-box; a floor. |image| needs both. |
max-width | number | — | caps an auto width; a width above it is invalid; text inside wraps to it (SPEC 5). |
text-wrap | wrap · nowrap | wrap | whether text breaks to honour max-width; inert without one (SPEC 5). |
padding | N · v h · t r b l | 0 (block) · 20 (box) | inner padding; places content. |
pin | none · center · edge · corner | none | out-of-flow anchor; a box property (not text). |
translate | x y | — | post-placement nudge; any node incl. text. |
rotate | degrees | 0 | turn about bbox centre; any node incl. text. |
layer | integer | 0 (flow) · 1 (pinned) | paint order; ties → source order. |
scale | number > 0 | 1 | the drafting ratio (2 = 2 : 1) — nearest-wins; position scales by the parent, shape by self (SPEC 15.1). |
pattern | grid(…) · radial(…) | — | replicate about the node’s position (SPEC 15.4). |
mirror | axis list · none · auto | auto | reflect the node’s path and features about the axis through its origin; auto reflects iff an ancestor does (SPEC 15.3). |
Media & accessibility — any node (href also a link):
| Property | Value | Notes |
|---|---|---|
href | quoted URL | wraps the node / link in <a href> — clickable. |
hint | quoted string | emits a <title> child (tooltip + screen-reader name). |
Type-owned properties
Read on the listed primitive; required where noted (SPEC 7).
| Property | On | Value | Notes |
|---|---|---|---|
points | |line| |poly| | x y, … · parametric u expr | vertex list; required. |
samples | |line| |poly|, chart fn: | integer | sample count (geometry default 2 — a straight segment; chart default 24). |
path | |path| | quoted SVG path | required; native top-left coords. |
src | |image| | quoted URL / data URI / local path | required; a local file embeds (SPEC 7). |
symbol | |icon| · |surface-finish| · |label| · the discretes · |door| and the floorplan fixtures (not |stairs|) | ident | Phosphor name, required (or via the label) · the finish vee variant — basic·machined·prohibited, default basic (SPEC 15.9) · a schematic symbol / variant (SPEC 16.3, SPEC 16.4) · a floorplan variant (SPEC 15.11). |
fit | |icon| |image| | auto · contain · cover · stretch | maps content into the box (size unchanged); auto default, |sign| contain. |
skew | |slant| | degrees (-89,89) | 15. |
multiple | closed primitives | N · dx dy | one offset duplicate behind. |
marker · marker-start · marker-end | |line|, links | see SPEC 7 | endpoint / vertex glyphs; from the operator on a link. |
draw | |sketch| | pen calls + :segments | required (SPEC 15.3). |
revolve | |sketch| | x-axis / y-axis | solid of revolution — fused fold + |shoulder| lines (SPEC 15.3). |
thread | |sketch| |hole| round geometry | seg pitch, … · pitch | ISO 6410 thread dressing (SPEC 15.3, SPEC 15.4). |
sheet | |page| | a5…a0 / ANSI a…e [portrait | landscape] | trimmed-size sugar → width / height in mm (SPEC 15.8). |
break | |sketch| | a b [axis] groups | cut the view between stations (SPEC 15.3). |
Grid, tree, chart, pie, sequence, drawing, floorplan & schematic properties
Layout-owned — an error only where a hard gate exists (SPEC 21); otherwise inert out of scope.
| Property | Owner | Value | Default | Ref |
|---|---|---|---|---|
layout | any container | flow·grid·stack·tree·sequence·chart·pie·drawing·floorplan·schematic | flow | SPEC 11 |
direction | flow, chart, tree | row·column · radial (chart) · bilateral (tree) | row (flow) · column (a closed shape’s or a |topic|’s card content, chart, tree) | SPEC 11 |
gap · gap-fill · align · justify · padding | flow, grid | — | see matrix (gap 36 in a flow, 12 in card content) | SPEC 11, SPEC 12 |
columns · rows | grid · schematic (columns — its own ordinal tracks, SPEC 16.1) | track list · a schematic’s is the wrap count, an integer ≥ 1 | — (columns required on a grid) | SPEC 12 |
cell · span | grid box child; cell also a schematic’s — ordinal (SPEC 16.1); span also a |space|’s slots (SPEC 16.2) | col row / cols rows | — / 1 1 | SPEC 12 |
data · fn | chart series | list / pairs / (…) expr | — | SPEC 14.3 |
labels | chart series | quoted-string list | — | SPEC 14.3 |
curve | |line| |area| | linear·smooth·step | linear | SPEC 14.2 |
baseline | |area| | number | axis zero | SPEC 14.2 |
axis | series, |mark|, |band| | an |axis| id | — | SPEC 14.4 |
bars · categories · samples | |chart| | see SPEC 14.1 | grouped · indices · 24 | SPEC 14 |
hole | |pie| | 0 ≤ n < 1 | 0 | SPEC 14.7 |
legend ⌛ · tooltip | |chart| |pie|, series (tooltip) | see SPEC 14 | auto · auto | SPEC 14 |
value | |slice| |bubble| | number ≥ 0 | — | SPEC 14 |
at | |mark| |bubble| · |plane| · an opening (|door| / |window| — its station on on:’s segment) | V / X Y · N [x-axis | y-axis] · N | — | SPEC 14.5, SPEC 15.8, SPEC 15.11 |
side · range · scale · step · ticks · unit · gridlines | |axis| (range also a |band|’s extent — SPEC 14.5) | see SPEC 14.4 | — | SPEC 14.4 |
format | chart / drawing scope · |axis| · series · a dimension — inherits | auto (a chart tick to 4 decimals, a dimension to 2, zeros trimmed) · decimal N · significant N · scientific N · engineering N · percent N · fraction D · date preset (year·month·day·hour·minute) | auto | presentation only, never measurement; composes before unit:, tol:, the ⌀/R/° glyphs, and N× counts (SPEC 14.4, SPEC 15.6) |
side (homonym: also an |axis|’s, above, and a dimension’s, below) | first-level |topic|, bilateral | left · right | the split rule | SPEC 12 |
place | sequence |note| | over · left · right, then id(s) | — | SPEC 13 |
activation | |sequence| | auto · none | auto | SPEC 13 |
scale (homonym: an |axis|’s is linear·log·time) | any node | number > 0 | 1 | SPEC 15.1 |
unit (homonym: an |axis|’s is its quoted tick suffix) | datum scopes · |axis| | px·mm·cm·m·in — an authored one inherits | mm (a drawing) · px (a stack) | SPEC 15.1, SPEC 14.4 |
density | the root | number > 0 | 4 | px per mm, screen/raster only (SPEC 15.1) |
tol | a dimension · a control row | t / +u -l / fit ident · number > 0 (a frame’s zone width) | — | SPEC 15.6, SPEC 15.9 |
characteristic | a control row (|control|, or a one-row |feature-control|) | the ISO 1101 ident set | — (the smart label) | SPEC 15.9 |
zone | a control row | diameter · spherical | — | SPEC 15.9 |
material | a control row | maximum · least | — | SPEC 15.9 |
datums | a control row | letters, each + optional maximum / least — ≤ 3 | — | SPEC 15.9 |
modifiers | a control row | projected N · free-state · tangent-plane list | — | SPEC 15.9 |
side | a dimension / callout (also |axis|, above) | side · corner · left / right along an aligned span | by axis | SPEC 15.6 |
project | a (-) dimension | horizontal · vertical · aligned | inferred | SPEC 15.6 |
gap | a mate | signed number — separation along the normal (a dimension stands off by clearance — SPEC 21) | — | SPEC 15.5 |
facing | |plane| | left·right·up·down | by plane | SPEC 15.8 |
of | |drawing| | a |plane| / |magnifier| id | — | SPEC 15.8 |
| ISO 7200 fields | |title-block| | quoted string | — | SPEC 15.8 |
thickness | a floorplan scope · |wall| — inherits, nearest wins | number > 0 | 200 mm | SPEC 15.11 |
on | |door| |window| | a straight wall :segment, bare | — required | SPEC 15.11 |
hinge | |door| | start · end | start | SPEC 15.11 |
swing | |door| | left · right | left | SPEC 15.11 |
steps | |stairs| | integer ≥ 2 | — required | SPEC 15.11 |
number | |pin| | integer | — | SPEC 16.2 |
prefix | |component| lineage, the discretes | quoted string | the type name (|component|: "U") | SPEC 16.2 |
shape | |label| | plain·left·right·both·round | plain — a label wire’s marker sets it | SPEC 16.4, SPEC 16.5 |
pins | |J| | integer ≥ 1 | — | SPEC 16.2 |
side (homonym) | |pin| | left·right·top·bottom | the bilateral split (left on a |J|’s generated pins) | SPEC 16.2 |
side (homonym) | a |label| · a schematic wire | left·right·top·bottom | above a horizontal run, the freer side of a vertical one | which side of its trace a net name sits (SPEC 16.4) |
Link properties
A link is styled like a node (SPEC 9) — its wire takes stroke*, its labels the
text props. Its own properties:
| Property | Value | Default | Notes |
|---|---|---|---|
clearance | number | 16 — a drawing, schematic, or chart scope seats its own (SPEC 10.5) | min gap from nodes and links; a dimension’s packing stand-off (SPEC 15.6); a chart’s chrome text off what it labels (SPEC 14.6). One meaning: the minimum daylight a thing keeps off what it must not touch. Scene config — cascades. |
routing | orthogonal · natural · straight | orthogonal | wiring strategy; scene config, cascades (ROUTING.md). |
along | fraction list | auto | label positions along the route. |
marker · marker-start · marker-end | marker | from the operator | endpoint glyphs (SPEC 7). |
corner-radius | number · auto | auto — the clearance-derived cap | a wire’s corner rounding radius (ROUTING.md, Model step 6); a schematic scope’s link default is 0 (SPEC 16.5). |
18. SVG Output
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="X Y W H" width="W" height="H" class="lini lini-scope-HHHHHHHH">
<style>
@layer lini.defaults {
:root, .lini-scope-HHHHHHHH { color-scheme: light dark; /* --lini-*: light-dark(…, …) */ }
.lini-scope-HHHHHHHH[data-theme="dark"], [data-theme="dark"] .lini-scope-HHHHHHHH { color-scheme: dark; }
.lini-scope-HHHHHHHH[data-theme="light"], [data-theme="light"] .lini-scope-HHHHHHHH { color-scheme: light; }
}
.lini-scope-HHHHHHHH { font-family: var(--lini-font-family); font-size: 15px; font-weight: var(--lini-font-weight); color: var(--lini-text-color); }
.lini-scope-HHHHHHHH .lini-canvas { fill: #eef; } /* only when the scene sets a background */
.lini-scope-HHHHHHHH .lini-box { fill: var(--lini-fill); stroke: var(--lini-stroke); stroke-width: 2; }
.lini-scope-HHHHHHHH .lini-style-hot { stroke-width: 3; } /* one rule per class def */
.lini-scope-HHHHHHHH .lini-link { stroke: var(--lini-stroke); stroke-width: 2; fill: none; }
</style>
<defs><!-- filters, gradients, clipPaths --></defs>
<rect class="lini-canvas" .../> <!-- …and then this plate, over the viewBox -->
<g class="lini-scene"> <!-- scene tree --> </g>
<g class="lini-links"> <!-- links --> </g>
</svg>
A figure paints no background it was not given. The lini-canvas plate — rect
and rule — is emitted only when the scene asks for one: a root fill: (a schematic
root’s --lini-sheet wash rides exactly this), or --static, whose output is a
standalone document for renderers with no CSS variables and so carries its own opaque
--lini-bg backdrop. Otherwise there is no rect and no rule, so a figure inlined in a
page shows the page through it with nothing to override. fill: --bg on the root is
how a live figure asks for the themed backdrop; fill: none is that default said out
loud.
viewBox auto-sizes to content + the scene’s padding (20 px by default) on every
side. When a file’s drawn content
is only |page|s (SPEC 15.8), the root width
and height carry the sheet’s trimmed size in real millimetres rather than pixels,
so a print is true-scale; the viewBox is unchanged, so on-screen layout and CSS sizing
are not.
Names are content-addressed. Two figures inlined in one HTML document share its
id and selector spaces, so every name Lini writes into either comes from the thing
it names: a <defs> id from its definition, an asset’s prefix from its bytes, a
glyph from its outline, the root’s lini-scope-HHHHHHHH class from its stylesheet’s
text. Figures then collide only on equal things, where sharing is correct —
url(#…) resolves to an equal def, a duplicate rule is a no-op. That class heads
every selector in place of .lini (one class either way, so specificity and host
overrides are unchanged); lini stays on the root as the host hook.
Paint compiles to CSS; geometry bakes. Node and link paint defaults — and every
rule — are stated once as class rules; only the classes actually used are emitted — and
likewise only the --lini-* variables actually referenced, so the built-in palette
(SPEC 10.2) adds nothing unless a diagram uses it. A node whose
resolved paint differs from those rules carries the difference as an inline style="…"
(inline beats class, mirroring the cascade). Geometry —
sizes, positions (pin and translate fold into the baked origin), radii, points, paths,
transforms — is always baked into attributes. Inherited text properties state on .lini
and cascade natively; a node’s own text property emits on its <g> (or directly on the
<text>) and inherits to its subtree.
Fonts — three output modes (SPEC 6). By default the SVG
carries names only — zero font bytes; the stack leads with the bundled family
names, so an installed or hosted copy engages. --embed-font inlines a base64
@font-face per family × weight actually used, under Lini-scoped family names
(never colliding with a user’s installed versions) — browser-faithful and
browser-only by design: resvg and librsvg ignore @font-face.
--static (SPEC 10.6) outlines text to paths — glyphs
deduplicated through <defs> / <use>, italic as synthetic oblique: faithful
in every renderer for the bundled charset (Latin-1, Latin Extended-A, general
punctuation). A run holding any other character — CJK, Arabic, an arrow, an emoji —
stays a <text> drawn by the viewer’s fonts, and the export warns, naming the
characters (SPEC 21); the same holds for a font-family override, whose
name is emitted but whose glyphs are not bundled (SPEC 6).
Layout never varies by mode — measurement always reads the compiled-in metrics
tables (SPEC 5), a character outside them at a flat estimate.
Embedded assets. A local |image| (SPEC 7) emits its resolved form:
an SVG asset nests as a child <svg> mapped into the node box (fit: sets its
preserveAspectRatio) — with every id prefixed lini-aHHHHHHHH- (a tag of the
asset’s own bytes) and every internal reference rewritten to match (url(#…)
in attributes and inline style, fragment href / xlink:href), since nesting
alone does not isolate ids; a raster asset emits
<image href="data:…;base64,…"/>. Authored URLs and data URIs emit unchanged.
Embedding is deterministic from the asset bytes.
Box:
<g class="lini-node lini-{type} lini-{base} lini-style-{class}"
data-id="ID" transform="translate(X,Y)">
<title>…</title> <!-- when `hint:` is set -->
<!-- geometry, then children -->
</g>
Auto-classes: lini-node (every box); lini-{name} (the type and every type it
inherits, down to lini-block); lini-style-{name} (per worn class). With rotation,
the transform becomes translate(X,Y) rotate(N).
Text emits a bare <text class="lini-text">…</text> at its placed position — no
wrapping <g>; a worn class joins it (class="lini-text lini-style-quiet"). A table’s
cells are |block|s wrapping their text, so each renders as a
<g class="lini-block …"><text>…</text></g>; the header and any |footer| cells carry
a fill, a body cell is frameless (SPEC 8). Text’s font and colour come by
inheritance from the enclosing <g>; a string’s own style block emits as a style="…"
(and translate / rotate as a transform) on the <text> itself.
Link:
<g class="lini-link lini-style-{class}" data-from="A" data-to="B">
<path d="…" fill="none" stroke="…"/>
<polygon class="lini-marker lini-marker-arrow" …/>
<text class="lini-text" …>label</text> <!-- placed by along: -->
</g>
Host CSS may restyle any lini--prefixed class; layout is computed at compile time, so
runtime restyling (a fatter stroke-width) restyles without re-layout. A chart’s or
sequence’s lowered primitives (SPEC 19) emit exactly like the boxes,
text, and lines above — a chart’s tooltip card is a <g class="lini-chart-tip">, a
reserved styling hook. Every generated dress is one rule, never a style= per
wearer, each rule emitted only when its role is actually worn, and an authored
class rule of the same name replaces the generated one (which is what lets
|halo| { … } restyle chrome scope-wide rather than layer under it). The hook
families:
| Family | Classes |
|---|---|
| core | lini-node · lini-{type} · lini-style-{class} · lini-text · lini-canvas · lini-gutter |
| link | lini-link · lini-link-label · lini-link-dashed / -dotted · lini-stray · lini-marker + lini-marker-{kind} (arrow·dot·circle·diamond·datum·dim·open) · lini-cut / lini-cut-bg (label mask) |
| chart | lini-chart-title · lini-chart-text (every other chart string) · lini-chart-legend · lini-chart-label · lini-chart-tip · lini-tip-N / lini-hit-N |
| sequence | lini-sequence-tab · lini-sequence-guard · lini-sequence-message |
| tree | lini-level-N · lini-hue-{name} (the mindmap walk) |
| drawing | lini-dim-line (dimension / leader linework) · lini-ext-line (--lini-stroke-light) · lini-dim-text (annotation text at the drawing’s link size, SPEC 10.5, and the link-label weight — no annotation leaf inlines either) · lini-dim (the restyled (-) tier’s compound, on dimension-owned chrome only) · lini-frame-cell / lini-frame-plate (GD&T) · lini-plane-end / -shaft / -arrow · lini-drafting-glyph · lini-datum-frame · lini-halo |
| floorplan | lini-door-leaf (a door’s leaf, a slider’s panels) · lini-door-swing (the quarter arc) · lini-window-sill · lini-stair-tread (a flight’s risers) · lini-stair-arrow (its up arrow) (SPEC 15.11) |
| schematic | lini-schematic-wire (a nested sheet’s dress) · lini-sch-line / -solid · lini-sch-tag-line · lini-tag-outline / -round / -flag-left / -flag-right / -flag-both · lini-net-run / lini-net-run-turned (a plain label’s run of trace, SPEC 16.4) · lini-pin-stub · lini-pin-number · lini-ref · lini-part-value |
| highlight | lini-tok-{kind} — a source listing’s token spans, not a figure’s: lini highlight writes them and lini highlight --css paints them (SPEC 20) |
| marker | lini-align-* / lini-justify-* (a table column’s carried alignment, SPEC 8) · lini-side-left / -right (which half of a bilateral tree a first-level topic fills, SPEC 12) · lini-pose-90 / -180 / -270 and lini-mirror-x / -y (a schematic part’s turn and flip, consumed at lowering, SPEC 16.1) · lini-carried (an annotation node riding a drawing statement’s [ ], SPEC 15.9) |
The last family is the odd one out: its classes carry structure, not paint. They emit no CSS rule and there is nothing in them for host CSS to restyle — the engine reads them back off the chain, and they are listed so nothing a figure emits is undocumented. Every other family above is a paint hook.
Generated ids are prefixed too — every one, without exception — each
tagged with what it names: lini-aHHHHHHHH- for embedded assets,
lini-shadow-HHHHHHHH / lini-clip-… / lini-gradient-… / lini-hatch-… /
lini-label-cut-… / lini-halo-… in <defs>, and --static’s glyph defs
lini-g{kind}{weight}-{gid} (an outline is equal whenever those three are, so
two figures share the def rather than collide). A detail view (|drawing| { of: <magnifier> }, SPEC 15.8)
clips to its region with one interned <clipPath> in <defs> and a clip-path= on
its group.
19. Compile Pipeline
A reference pipeline; implementations may differ if the observable output matches.
Parse. Lex to tokens, then a single recursive-descent pass to the AST. The
bracket-and-bars vocabulary (|…| identity, { } style, [ ] content) resolves every
statement with one token of lookahead — no type-set prescan (SPEC 22).
Desugar. Lower all surface sugar to primitives + classes — the engine’s true input. The pass is idempotent; type-system errors (cycle, depth > 16, a define shadowing a built-in) surface here. What becomes explicit:
- Types & rules: each template / define instance becomes its base primitive
wearing a
.lini-*class chain (derived → base → primitive, down toblockfor every rectangular type); a type’s defaults and any|type| { }element rule fold into a generated.lini-<type> { … }class; a|table| |box| { }descendant rule rewrites to.lini-table .lini-box { }, and|-|(the link type) to.lini-link— the class every link wears; define bodies inline per instance. - Scene config: the scene defaults (
layout,padding,gap,font-size,clearance,routing,density) settle on the root; a drawing (or floorplan) scope’sunit:× the rootdensity:fold into its one internal px-per-unit — its pixels per drawing unit at ratio 1, which the cascadingscale:multiplies at layout — and a floorplan scope’sunit:is stamped for its types’ mm defaults to convert through where each is read (SPEC 15.1, SPEC 15.11). - Statements: the per-type smart label (text / caption / symbol / link label /
chart title …); auto-
along:; chain expansion (a -> b -> c→a -> b; b -> c, auto-created ids included — fan-out&stays a resolve / routing concept; a schematic chain is the carve-out, SPEC 16.5); a tree’s branch links,.lini-level-Nclasses, and a mindmap’s palette-walk rules (SPEC 12); link auto-create (an undeclared endpointx→|box#x| "x"); and capsule hoisting (an endpoint capsule → a declaration at the statement’s position + a reference, anonymous ones under mintedlini-cap-Nids — SPEC 9). - Schematic lowerings: pin rails, ref readouts and minted display refs,
label-wire minting (
U7.DIAG - "NSTDBY"→ a|label|+ its wire), and the scope’s look rules (SPEC 16).
Resolve (top-to-bottom):
- Variables, functions & rules: merge visual-var defaults ←
--theme←--name: value; build the function table; compile the stylesheet’s class / id / element / descendant rules. Parenthesized expressions and function calls fold to literal numbers / points (SPEC 10.7). - Scene tree: each box is a primitive wearing
.lini-*(type) and user classes; layer properties per the cascade; lift internal links; build the path index. A|table|/|entity|’s structure settles here, once itscolumns:has — the header band, the carried column alignment, an entity’s full-width spans (SPEC 8). - Links: resolve endpoints by scoped path walk with suggestion errors; merge link
properties through the link’s ladder (SPEC 4);
cartesian-expand fan groups into one resolved link per pair; the
operator’s line sets
stroke-styleunless overridden.
Layout (bottom-up): leaf bbox from width/height or defaults (text → its glyphs;
box → content + padding; + half-stroke-width per side); arrange flow children per
layout / direction honouring align/justify/stretch/evenly when there is slack; pin
out-of-flow children to their parent anchor (the parent never grows for them); compute
gutters; apply padding; apply each node’s translate; rotate last. A layout-owning
container — sequence (SPEC 13), chart / pie
(SPEC 14.9), and drawing / floorplan (SPEC 15.10,
SPEC 15.11) — instead
reads its whole subtree here and lowers it to primitives, consuming its own links,
so the router never sees them.
Route links. Per ROUTING.md — orthogonal, clearance-respecting,
deterministic — over every link except those a drawing scope already drew;
a sequence’s messages route straight with layout-fixed anchors.
Place markers (sized max(5, stroke-width × 4) + 1, tip on the endpoint) and link labels at
their along: fractions (auto-distributed when unset).
Render. Depth-first emit SVG per SPEC 18: a box is a <g>, a string is a
<text>. A lowered chart / sequence subtree renders as ordinary primitives
(SPEC 11, seam 3).
20. CLI
lini [options] <input.lini>
lini fmt [--check] [--stdout] <input.lini>
lini desugar <input.lini>
lini highlight <input.lini>
lini highlight --css
lini serve [--port N] [--static] [--theme NAME|FILE|A/B] [PATH]
lini theme [NAME]
| Flag | Meaning |
|---|---|
-o FILE | Output path (default stdout). |
--format svg|html | svg (default) or HTML wrapper. |
--check | The full compile, no artefact written — the same errors and warnings, the same exit code. (fmt --check is that subcommand’s own flag — below.) |
--port N | lini serve only — the preview port (default 7700). |
--json | Emit diagnostics as a JSON document (stable codes, severity, spans, related spans, machine-applicable fixes — SPEC 21) instead of SVG; the tooling/LSP form. Exit 1 if any error-level diagnostic fired. |
--theme NAME|FILE|A/B | A built-in theme (dark, high-contrast, …), a CSS file of --lini-* overrides, or a light/dark pair (light/dark). |
--no-warn / --strict | Silence warnings / treat them as errors — under every form (--check, --json, SVG) alike. |
--static | A self-contained SVG that renders identically anywhere (SPEC 10.6, SPEC 18). |
--embed-font | Embed the used bundled family × weights as base64 @font-face — browser-only (SPEC 18). Both font flags need the default-on font build feature; name-only output never does. |
--watch | Recompile on every input change (requires -o). |
-h, -V | Help / version. |
lini - reads stdin (filename <stdin> in errors). lini serve runs a local live
preview (default port 7700): a .lini file live-reloads that one file; a directory (or
no path → the current directory) opens the playground — pick, edit, and render any
.lini file beneath it in the browser. A served compile reads image assets
(SPEC 7) under the same boundary that confines the file list: the served
root — a file target’s root is its directory — and an asset path escaping it is a
compile error; a plain lini compile is unbounded (you compile your own file). lini theme lists the built-in themes;
lini theme NAME prints one as a --lini-* CSS file — a ready starting point for
your own (light-dark() colours, the font commented out).
lini fmt reformats to canonical style — 2-space indent, key: value;
declarations grouped on one line, a style-only node collapsed onto its head line when it
fits (|box#api| { fill: red }), a lone label trailing the head (|box#api| "API"),
children one per line in [ ], table cells padded into aligned columns — a styled
cell ("Apple" { color: --red-ink }) keeps its block and its row steps out of the
aligned grid; unstyled rows stay aligned — a draw:
value broken before each move() and wrapped between calls at the column limit
(continuations indented, so a profile reads as its subpaths), comments and
blank lines preserved — a comment opening its line stays leading, one
following code stays on that code’s line, since it annotates the statement it
trails. --check exits 1 if it would change anything; --stdout writes
instead of rewriting.
lini desugar prints the file fully lowered to primitives — the Desugar pass
(SPEC 19) that is the engine’s true input — so the lowered form
re-renders byte-identically. A chart’s or sequence’s type desugars here (a |chart|
is a |block| wearing .lini-chart); its geometric primitive subtree is a layout-phase
artefact (SPEC 19), like a routed link’s geometry. A
teaching/debugging view; prints to stdout, never rewrites, comments not preserved.
A |table|’s header band and its per-column alignment are likewise not
shown: both are decided from the resolved columns: (SPEC 8),
which any tier of the cascade can set, so they are a cascade-phase artefact —
the structure desugar does show (each cell in its |cell|, an entity’s label
as its title |header|) is the part that
needs no column count.
lini highlight prints the file as <span class="lini-tok-…"> HTML — the
one syntax highlighter, at a shell. It is lexical: it never parses, so a
file mid-edit still colours and the only failure left is I/O; and it is
byte-preserving — strip the tags, undo the four entity escapes, and the
source comes back exactly, which is what lets a host drop the output into a
<pre> and trust the listing. Newlines pass through as newlines (a caller that
cannot carry one rewrites them itself). The classes are the token kinds, under
the reserved prefix like every other name Lini writes into a host document
(SPEC 18, SPEC 23): lini-tok- +
comment · string · number · const · keyword · type · type-user ·
prop · prop-user · var · op · class · punct. The words behind them
come from the same source the editor grammars do (SPEC 22), so a
new type or property colours the moment it has a ledger row.
lini highlight --css prints the token palette those spans wear —
nine --lini-tok-* role variables as light-dark() pairs, then the rules that
paint the thirteen classes from them — so the markup and its colours come from
one place and a listing reads the same in a book, on a site, and in the
playground. The role defaults are layered (@layer lini.defaults), so a host
re-tints one by redeclaring its variable with no !important; the sheet sets no
color-scheme, leaving the light/dark choice to whatever the host has set on
the listing’s ancestors.
The same scanner is lini::highlight_html to a crate and highlight() to the
browser build; a host that can link Rust should, and this subcommand is for the
one that cannot.
Exit codes: 0 success · 1 parse/resolution error or --check reformat needed · 2 I/O ·
3 invalid CLI.
21. Errors
Format: filename:line:col: error: <message> (LSP-compatible), compile-time, with a span.
--strict promotes warnings to errors; --no-warn silences them (SPEC 20).
Every diagnostic carries a stable code — a phase letter (Lex · Parse · Resolve ·
Validate · laYout · rouTe · Output) then a 3-digit number, e.g. V001. Codes are stable once
assigned; the message may still improve. The implementation’s diagnostic
registry is the authority for code assignment — this section’s tables and
their ordering carry no codes. The human form above stays code-free; lini --json
(SPEC 20) emits the structured record — code, severity, span, related span, and a
machine-applicable replacement where one exists.
What the --json document freezes. { "file", "diagnostics": [ … ] }, each
entry code · family · severity (error / warning) · message ·
span, then related and suggestion (span · replacement ·
applicability) where the diagnostic has them; every span carries
start · end (byte offsets) and 1-based line · col · endLine ·
endCol. A tool may rely on that shape and those codes; a message
may be reworded to read better, and a later release may add fields — never
rename or drop one. A clean file emits an empty diagnostics array, not an
error.
Lexing
| Condition | Message |
|---|---|
| Unclosed string | unterminated string literal |
| Bad number | invalid number literal |
| Bad escape | invalid escape sequence '\X' |
| Stray character | unexpected character 'X' |
| Operator outside a group | math operators appear inside ( ) — e.g. padding: (8 * 2) |
Properties & validation (SPEC 17’s strict/lenient rule)
| Condition | Message |
|---|---|
| Unknown property name | unknown property 'colr'; did you mean 'color'? |
| Misused property, wearer known | 'points' has no meaning on '|box|' — it reads on '|line|' / '|poly|' · 'cell' places a grid or schematic child — this box sits in a 'layout: flow' |
| Property dead for every wearer | '.hot { cell: … }' is inert on every wearer (warning) |
| Class defined, never worn | class '.hot' is never worn (warning) |
| Malformed value | 'opacity' is a fraction 0..1 · 'translate' takes 'x y' · 'padding' takes one value, not a comma list · 'wavy' waves a link's wire — a shape's outline takes solid, dashed, dotted, center, or phantom |
| A space-separated list where the comma law reads items | 'data' takes comma-separated values — 'data: 9, 15, 24' (SPEC 2) |
| Deferred property | 'legend' is named but not built yet — see SPEC 24 — a named-but-unbuilt row (SPEC 24) errors, so accepting it can never freeze the non-behaviour |
| Property on a link that has no link meaning | 'routing' is a scope's strategy — one scope, one strategy; set it on the container (clearance: is a link’s, ROUTING.md) |
radius on a non-rect primitive | 'radius' rounds a rect or a polyline join — rounding a '|hex|' is deferred (SPEC 24) |
Arbitrary numeric font-weight | 'font-weight' takes normal, medium, semibold, bold, or 400, 500, 600, 700 (100–900 is deferred) |
| Gradient in a text-colour slot | 'color' takes a flat colour — a gradient fills a shape, and gradient-on-text is deferred |
% outside a colour component | 'width' takes a number — a '%' is a colour component (SPEC 2) |
Identity, cascade & statements
| Condition | Message |
|---|---|
| Duplicate id | duplicate id 'X' (previously at L:C) |
| Unknown type / class | unknown type 'X' / unknown class '.X' |
| Inheritance cycle / depth | cycle in 'X → … → X' / 'X' exceeds max inheritance depth (16) |
| Define shadows builtin | 'X' shadows a built-in type |
| Empty bars | '| |' needs a type or an '#id' |
| Invalid id | '#123' is not a valid id — an id starts with a letter or '_' |
| Reserved id prefix | an id may not begin 'lini-' — the prefix is reserved for generated names |
| Class inside the bars | a class follows the bars — write '|box| .hot', not '|box.hot|' |
| Symbol set twice | an icon's symbol is its label or 'symbol:', not both |
| Text carries children | text content takes no '[ ]' — wrap it in '|block|' to give it children |
| Box property on text | 'pin' needs a box — wrap the text in '|block|' |
| Declaration outside a block | a declaration belongs in a '{ }' block |
| Bare node on the canvas | a node leads with bars — write '|box#X|' (a bare name is a link endpoint) |
| Bare type in the stylesheet | a type only appears in bars — write '|box| { }' to style every box |
| Missing declaration ‘;’ | a declaration ends with ';' |
| Style block holds non-decl | a '{ }' style block holds only declarations |
[ ] holds a declaration | declarations go in '{ }', not '[ ]' |
| Two head labels | one inline label — put two or more in a '[ ]' |
| Label after a class | a label comes before classes — write '|box| "X" .hot' |
| Stylesheet after canvas | the stylesheet '{ }' must come first, before any instance |
| Glued compound in a rule | a selector unit can't glue a type and a class — space them (descendant) or style '.hot' |
| Spaced class chain | classes glue into a chain — write '.hot.loud', no space |
Links & routing
| Condition | Message |
|---|---|
| Unknown endpoint (path) | link endpoint 'X' not found at <scope> + ; did you mean 'A', 'B'? |
| Auto-create shadows a node | endpoint 'X' auto-created at <scope> — a node 'X' also exists at 'A.B.X' (warning) |
| Auto-create near-miss | 'cta' auto-creates a new box; did you mean 'cat'? (warning — edit distance ≤ 2 or case-fold vs names known in scope, SPEC 3) |
| Chain mixes operators | link chain mixes operators 'X' and 'Y' |
| Chain < 2 nodes | link requires at least two endpoints |
Bare o marker | '-o' needs a max glyph — write '-o<' or '-o+'; the hollow ring is an ER component only |
| Missing required property | '|line|' requires 'points' |
-> in the stylesheet | '->' draws a link on the canvas — style every link with '|-| { stroke: … }' in a '{ }' block |
|-| / |link| as an instance | a link is drawn by an operator — '|-|' only styles links (write 'a -> b') / links are drawn by operators, not the '|link|' type |
|node| as instance | 'node' is the umbrella concept — write '|block|' for the bare box |
| Unknown routing strategy | routing takes orthogonal, natural, or straight |
| Unknown side | ':X' is not a side — use top, bottom, left, or right |
| Link labels split | keep a link's labels together — write 'a -> b [ "x" "y" ]' (warning) |
| Capsule endpoint in a drawing | a drawing never invents an endpoint — declare the node, then annotate it |
| Pin path on an inline component | '|component#U9|.p4' — an inline component has no authored pins |
Values, colour & expressions
| Condition | Message |
|---|---|
| Invalid / out-of-range color | invalid color 'XYZ' / rgb(300,0,0): component out of range |
Invalid oklch() | oklch expects (L, C, H) or (L, C, H, A) — L and A in 0..1, C ≥ 0, H in degrees |
| Gradient with < 2 stops | gradient() needs at least two colour stops |
linear-gradient without an angle | linear-gradient needs an angle first, then ≥ 2 colour stops — e.g. linear-gradient(135, --teal, --sky) |
| Single-quoted string | single quotes are not strings — use "…" |
| Unquoted text value | 'title' takes a quoted string — write title: "…" |
Invalid pin value | 'pin' expects none, center, an edge (top/bottom/left/right), or a corner (e.g. 'top right') |
Negative container gap | a container's 'gap' must be ≥ 0 — a mate’s gap: may go negative (SPEC 15.5) |
skew out of range | skew: N must be in (-89, 89) |
| Unknown name in an expression | unknown name 'foo' in an expression |
| Function arity | 'sin' takes 1 argument(s), got 2 |
| Spaced call paren | a call's '(' glues to its name — write 'rgb(…)' |
hatch() off fill | 'hatch' is a fill — 'stroke' takes a colour or gradient |
| Unreadable image path | cannot read image './logo.svg' — no such file (SPEC 7) |
| Asset escapes the served root | '../secret.svg' resolves outside the served root (SPEC 20) |
Layout — grid
| Condition | Message |
|---|---|
Missing columns | 'layout: grid' requires 'columns' |
| Empty / bad track | 'columns' needs at least one track / a track is a size, 'auto', or repeat(N[, size]) |
| Grid out of range | cell: 5 _ exceeds columns=3 |
Layout — wrap (SPEC 5)
| Condition | Message |
|---|---|
nowrap text can’t fit | text cannot fit 'max-width: 80' without wrapping — widen it or drop 'text-wrap: nowrap' |
| Non-text child wider than the cap | a child is wider than 'max-width: 80' — only text wraps |
width above max-width | 'width: 200' exceeds 'max-width: 120' |
Layout — tree (SPEC 12)
| Condition | Message |
|---|---|
|topic| outside a tree | '|topic|' builds a tree — it belongs in a 'layout: tree' |
| No root topic | a tree needs exactly one root '|topic|' |
| A second root topic | a tree has one root — '|topic|' 'X' is a second |
side: top/bottom in bilateral | a bilateral tree grows left and right — 'side' takes left or right |
side: in row / column | 'side' picks a bilateral branch's half — this tree has one growth direction |
Unknown direction: | unknown direction 'radial' — a tree grows column, row, or bilateral (a ring-radial tree is deferred, SPEC 24) |
Layout — sequence
| Condition | Message |
|---|---|
| Sequence node outside a sequence | '|loop|' belongs in a 'layout: sequence' (same for |opt| / |alt|; a |note| is core — SPEC 8) |
|else| outside an |alt| | '|else|' separates an '|alt|' — write it inside one |
|note| in a sequence, no placement | a sequence '|note|' needs 'place:' |
Bad place: | 'place' is a mode then its lifelines — 'place: over api db', 'place: left api' |
| Sequence property off a sequence | 'place' is valid only in a 'layout: sequence' (same for activation) |
Layout — chart & pie
| Condition | Message |
|---|---|
| Series / axis / band / mark outside a chart | '|bars|' is a chart series — it belongs in a 'layout: chart' · '|axis|' belongs in a 'layout: chart' |
|slice| outside a pie | '|slice|' belongs in a 'layout: pie' |
| Pie given an axis or series | a pie's children are '|slice|' only |
A |slice| with children | a '|slice|' is one wedge — multi-ring pie / sunburst is deferred (SPEC 24) |
| Empty chart / pie | a chart needs at least one series / a pie needs at least one '|slice|' |
Series with both / neither data: fn: | a series takes 'data' or 'fn', not both / a series needs 'data' or 'fn' |
arrow / crow marker on a series | 'marker: arrow' has no centred form on a chart — use dot, circle, or diamond |
fn: list ≠ band count | 'fn' has N formulas but the chart has M bands |
| Data ≠ categories count | series data has N values but the chart has M categories |
labels: count ≠ data count / on fn: | 'labels' has N entries but the series has M data points / 'labels' needs explicit 'data' |
categories: + an axis text | set 'categories' or an axis 'labels', not both (⌛ — reachable once per-axis tick text lands, SPEC 24) |
|mark| without axis: / bad at: | a '|mark|' needs 'axis:' to place it / 'at' takes one value (a line) or two (a point) |
|bubble| missing at: / value: | a '|bubble|' needs 'at:' (x y) and 'value:' |
Unknown axis: id | axis 'X' not found + ; did you mean 'Y'? |
range: bad / equal ends | 'range' takes two ends: 'a b', 'a auto', or 'auto b' / 'range' needs distinct ends |
span: on a |band| | a band's extent is 'range: a b' — 'span' places a grid child (SPEC 14.5) |
scale: log over a non-positive domain | a 'scale: log' axis needs a domain above 0 |
| Paint list count ≠ data count | 'fill' lists N paints but the series has M data points |
Paint list on |line| / |area| | a '|line|' is one shape with one paint — per-datum lists read on '|bars|' / '|dots|' |
| Mixed date / numeric domain | an axis reads dates or numbers, never both — one domain, one kind |
| Invalid date literal | '2026-13-01' is not a date — ISO-8601: '2026-01-31', optionally 'T09:30' and 'Z' |
Numeric step: on a time axis | a time axis steps by calendar — 'step: month', 'step: 2 week' |
Bad format: value | 'format' takes auto, decimal N, significant N, scientific N, engineering N, percent N, fraction D, or a date preset |
side: in direction: radial | 'side' has no meaning in a radial chart — it has one radius axis |
|band| / |mark| in direction: radial | a radial chart draws no bands / marks yet — remove it or change 'direction' (SPEC 24) |
hole: out of range | 'hole' is a fraction 0..1 |
| Negative slice value / pie total zero | a '|slice|' value must be ≥ 0 / a pie's slice values sum to zero |
Layout — drawing (SPEC 15)
| Condition | Message |
|---|---|
|sketch| without draw: | '|sketch|' requires 'draw' |
|hole| / |pitch-circle| / |magnifier| without width: | '|hole|' requires 'width' — its diameter |
| Unknown pen call / arity | unknown draw call 'X' / 'arc' takes (dx, dy, r) or (r, deg) |
fillet / chamfer off a corner | 'fillet' modifies the corner between two segments |
Floating :segment | a ':segment' glues to its call — name a station with point():v |
Bare point() | 'point()' names the pen's position — attach a ':segment' |
| Arc radius too small | arc radius N is smaller than half the chord |
Bad mirror: item | 'mirror' takes x-axis, y-axis, a bearing, or none |
Axis mirror: on |path| / |image| | '|path|' has no reflection — draw it with the pen |
Bad break: group | 'break' takes two stations 'a b' — a < b — and an optional x-axis / y-axis |
break: off a sketch | 'break' cuts a '|sketch|' — draw the profile with the pen |
break: station off the profile | 'break' at N misses the profile |
Overlapping break: groups | 'break' spans overlap — merge them |
break: through a cubic | a 'break' can't cut a 'curve()' — move the stations (SPEC 24) |
| Drawing statement outside a drawing | '(-)' draws a dimension — it belongs in a 'layout: drawing' (or its 'floorplan' dialect) (same for (o), (<), ||, corner anchors, tol:, …) |
| Unknown endpoint | dimension endpoint 'X' not found at <scope> + suggestions — never auto-created |
| Corner order | ':right-top' is not an anchor — did you mean ':top-right'? |
(>) | '(>)' is reserved — the angle op is '(<)' |
One-ended (-) / || | a linear dimension measures two anchors / a mate seats two parts |
Two-ended (o) | '(o)' measures one round feature — write 'a:top (o)' for a span |
| Empty one-ended leader | a leader needs its text — 'bolt <- "THRU"' |
One-ended -> / -* | a leader points back at its feature — write 'a <- "…"' |
Bare (o) with no axis | '(o)' can't pick an axis on 'X' — anchor a side ('X:top (o)') or a segment |
(<) on a point anchor | an angle reads two edges — a named segment, a '|line|', or a side |
Unary (<) on an unmirrored name | '(<)' on ':taper' needs 'mirror:' — no twin to measure against |
Station ⌀ on a mirror-only profile | a station '⌀' reads a revolved profile — 'revolve: x-axis' |
revolve: + mirror: together | a sketch takes 'revolve:' or 'mirror:', not both |
Bad revolve: value | 'revolve' takes x-axis or y-axis |
Bad thread: group | 'thread' takes a segment and its pitch — 'thread: m8 1.5' |
thread: without revolve: | 'thread' dresses a revolved profile — add 'revolve: x-axis' |
thread: segment off-axis / not straight | 'thread' runs along the axis — 'm8' must be a straight run parallel to it |
Unknown thread: segment | no segment 'm8' in this 'draw:' + suggestions |
thread: on a non-round node | 'thread' dresses a '|sketch|' segment or a round feature |
Bad sheet: | 'sheet' takes a size — a5…a0 (ISO) or a…e (ANSI) — and an optional portrait / landscape + did-you-mean |
of: finds no marker | 'of' finds no marker 'X' |
of: names a non-marker | 'of' names 'X', not a '|plane|' or '|magnifier|' |
| Detail of a sourced view | a detail magnifies a base view — 'of' can't name a marker inside another sourced view |
at: off the model | a 'plane' at N sits off the model |
Bad facing: | 'facing' turns the arrows — left, right, up, or down |
| Marked projection op | a projection line is unmarked — write 'side.screw:head - end.od:top' |
| Projection ends in one view | a projection link ties two views — both ends read 'side' |
| Projection end off a view | a projection link ties drawing anchors — 'notes' is not in a drawing view |
| Cross-view dimension / mate | a dimension reads one view — a cross-view correspondence is a construction link ('a - b') |
| Authored cell on a generated field | cell 2 1 is taken by the generated 'Rev' field — place it after the fields |
:segment shadows a built-in point | ':left' is a built-in anchor — pick another name |
Unknown :segment | no segment ':step' on 'body' + suggestions |
Duplicate :segment in one draw: | ':step' is already named in this 'draw:' |
| Label on a mate | a mate takes no label |
gap: on a point mate | a point mate coincides — 'gap' needs directed anchors (sides or named edges) |
| Non-parallel mate directions | mated anchors must face along one axis — 'a:left || b:top' has no shared normal |
| Over-constrained mate | mate over-constrains 'X' — already positioned via 'A || B' |
| Mate within one part | 'a' and 'b' are features of one part — a part is rigid |
| Perpendicular directed pair | 'a:left (-) b:top' — perpendicular faces have no shared normal; the angle between edges is '(<)' |
project: vs a directed anchor | 'project: vertical' conflicts with 'a:left' — the directed anchor reads horizontal |
| Unknown copy index | no copy 'bolt.5' — the replication places 4 |
| Duplicate datum letter | datum 'A' is already placed (previously at L:C) |
| Drafting type outside a drawing | '|feature-control|' annotates a drawing — it belongs in a 'layout: drawing' (or its 'floorplan' dialect) (same for |surface-finish|, |control|, |datum|) |
| Unknown characteristic | unknown characteristic 'flatnes'; did you mean 'flatness'? |
| Characteristic set twice | a control's characteristic is its label or 'characteristic:', not both |
| Unknown finish variant | 'symbol' picks the vee — basic, machined, or prohibited |
Control row without tol: | a control row needs 'tol' — its zone width |
| Mixed frame forms | a frame is one row or '|control|' rows — not both |
|control| outside a frame | '|control|' is a '|feature-control|' row |
datums: on a form control | 'flatness' is a form control — it takes no datum |
| Missing required datum | 'circular-runout' measures against a datum — name one in 'datums:' |
| Unknown datum reference | no datum 'D' in this drawing — declared: A, B |
| Too many datums | 'datums' orders primary, secondary, tertiary — three at most |
zone: off an axial control | 'zone: diameter' has no meaning on 'flatness' — its zone is a width, not an axis |
material: off a feature-of-size control | 'material' modifies a feature-of-size control — position, orientation, or straightness |
| Unknown modifier | 'modifiers' takes projected N, free-state, or tangent-plane |
| Point-target seat | a seat needs a face — anchor a side or a named edge ('sf || plate:top') |
| Seat with no geometry end | a seat stands an annotation on geometry — 'sf || n1' seats nothing |
| Annotation seated twice | 'sf' is already seated (previously at L:C) |
| Annotation node on a routed link | a routed link's '[ ]' holds text labels — annotation nodes ride a drawing's dimensions and leaders |
& fan on a measuring op / mate | '&' fans one-ended leaders — chain dimensions instead ('a (-) b (-) c') |
gap: on a dimension | a dimension stands off by 'clearance' — 'gap' is a mate's separation |
side: off-axis | a horizontal dimension stacks on top or bottom / a vertical dimension stacks on left or right |
Parallel (<) edges | the angle's edges are parallel — they never meet |
Bad tol: | 'tol' takes a number, '+upper -lower', or a fit ident |
Bad pattern: | 'pattern' takes grid(cols, rows, dx, dy) or radial(count, radius) (name and arity) · 'radial' needs count ≥ 2 and radius > 0 |
scale: ≤ 0 | 'scale' must be > 0 |
scale: on a |page| | a '|page|' carries no 'scale:' — 'density:' sets its pixels per millimetre (root), a drawing's 'scale:' its drafting ratio |
density: ≤ 0 | 'density' must be > 0 |
density: under unit: px | 'density' is pixels per millimetre — 'unit: px' has none; drop it, or state 'unit: mm' to scale (warning — only the identity density: 1 applies, SPEC 15.1) |
| Absurd rendered extent | the drawing renders 48000 px wide — 'scale:' is a ratio; a 5 m beam at 1:50 is 'scale: 0.02' (hint) |
Bad unit: / density: off the root | 'unit' is px, mm, cm, m, or in / 'density' is scene config — set it in the root block |
| Chain past a label | a text callout ends its statement — chain before it |
| Mate in a flow scope | a '|row|' places its own children — mates seat a drawing's |
| Empty drawing | a drawing needs at least one geometry child |
Layout — floorplan (SPEC 15.11)
| Condition | Message |
|---|---|
| Floorplan type outside the scope | '|wall|' belongs in a 'layout: floorplan' (every floorplan type) |
on: an unknown / curved segment | 'sout' is not a segment of this wall; did you mean 'south'? · an opening sits on a straight run — ':bay' is an arc |
| An opening off its segment | 'd2' at 1.8 + width 0.9 overruns 'side' (length 2.3) |
| Overlapping openings | 'entry' and 'w1' overlap on 'south' |
An opening outside a wall’s [ ] | a '|door|' rides in its wall's '[ ]' |
translate: on an opening | an opening sits at 'on:' / 'at:' — move the station, or nudge the wall |
curve() in a wall’s draw: | a wall bends with 'arc()' — 'curve()' has no offset |
hinge: / swing: on a sliding door | a sliding door has no leaf to hang — remove 'hinge:' / 'swing:' |
A wall segment authored *-in / *-out | ':north-in' collides with the derived face anchor — rename the segment |
| An arc wall tighter than its thickness | arc radius 40 is under thickness/2 — the inner face vanishes |
Missing on: / steps: | required-property errors, as points on a |line| |
Layout — schematic (SPEC 16)
| Condition | Message |
|---|---|
| Schematic type outside the scope | '|R|' belongs in a 'layout: schematic' (every schematic type) |
:side on a terminal | a terminal owns its connection — a pin or label takes no ':side' |
| Non-90° rotation on a connection-bearing part | a schematic part rotates in 90° steps — 0, 90, 180, or 270 |
A mirror: on such a part naming no axis of its own | a schematic part mirrors about its own axis — x-axis, y-axis, or none |
| Pinless wire to a 3+-pin part | 'U7' has 21 pins — name one ('U7.VS') |
| Both pins of a 2-pin part taken | both pins of 'R5' are wired — name one ('R5.p1') |
| Minted ref as an endpoint | link endpoint 'R1' not found — a minted ref is display-only; give the part an id to wire it |
| Marker on a part-to-part wire | a schematic wire is plain — markers shape a text label's tag; write 'a - b' |
| Marker at a symbol-form label | '|gnd|' draws its symbol — there is no tag to shape |
| Bare unknown id in the scope | 'NSTDBY' is unknown — a schematic never invents a box; did you mean '- "NSTDBY"' (a net label)? |
| Duplicate wire | 'a - b' is already wired — a repeated wire means nothing on a sheet |
| Dot-path into a label | a label is its own terminal — it has no pins |
Unknown schematic symbol: | unknown symbol 'gnb'; did you mean 'gnd'? |
Bad shape: / pins: / number: | 'shape' takes plain, left, right, both, or round — not 'X' · 'pins' takes a count ≥ 1 · 'number' takes an integer |
clearance past the fine pitch | 'clearance' 30 is past the sheet's pin pitch 20 — two wired pins stand one pitch apart, so no wire could keep it |
Non-integer columns | 'columns' in a schematic is the wrap count — a positive integer (its tracks size to their anchors) |
Two anchors on one cell: | cell 2 1 already holds 'U7' — give 'U8' its own ordinal |
| Satellite chain with no placed end | 'C7' has no placed end — its chain falls back to the flow (warning) |
| A chain’s third placed end | 'U3.a' also holds 'C7' — a chain distributes between two placed ends, so this one is dropped (warning) |
Routing — a stray’s reasons (ROUTING.md, Impossible layouts / Fixed ports); each is a warning naming the link, --strict an error
| Condition | Message |
|---|---|
| Closed graph | no legal route: every side entry or channel is closed at this layout |
| Blocked fixed port | fixed port blocked: a body covers the port's landing |
| Crowded fixed ports | fixed ports closer than the minimum pitch on one side |
| Conflicted fan | fan ends carry two different fixed ports |
| Pinned self-loop | self-loop with both ends forced onto one side |
Output (SPEC 18)
| Condition | Message |
|---|---|
--static cannot outline a run | no bundled glyph for '你', '好' in "你好" — under --static this text stays <text>, drawn by the viewer's fonts (warning) |
22. Grammar
file = [ stylesheet ] { drawn } # setup block, then drawn statements in source order
stylesheet = "{" { setup_item } "}" # the root's setup block; omit when empty
setup_item = decl | vardecl | binding | rule | define | comment | newline
drawn = node | text | link | comment | newline # instances and links interleave; a sequence reads order as time (SPEC 13)
decl = ident ":" values ";" # ';' optional before '}'
vardecl = css_var ":" values ";" # --name : value ;
binding = ident [ "(" [ ident { "," ident } ] ")" ] "=" value ";" # my_r = 5 ; scale(n) = (…) ;
rule = selector style # |box| { } , |table| |box| { } , .hot { } , #hero { }
define = "|" ident "::" ident "|" body # name :: base, optional children
node = ident_bars [ string ] [ classes ] [ style ] [ children ]
text = string [ classes ] [ style ] # bare content; a styleable leaf, never a box
ident_bars = "|" ( type [ "#" ident ] | "#" ident ) "|" # |type| , |type#id| , |#id|
type = ident
classes = "." ident { "." ident } # a worn class chain — .hot, .hot.loud
style = "{" { decl } "}" # declarations only
children = "[" { node | text | link } "]" # nodes, text, links — in source order
body = [ style ] [ children ] # define / container body
link = endpoints op [ endpoints ] { op endpoints }
[ string ] [ classes ] [ style ] [ label_block ] # the node tail, on a link head
op = link_op | draw_op
draw_op = "||" | "(-)" | "(o)" | "(<)" # mate, linear, round, angle (SPEC 15)
selector = sel_unit { sel_unit } # whitespace-separated = descendant
sel_unit = ident_bars | "|-|" | "(-)" | "." ident | "#" ident # a type(+id), the link type, the dimension type, a class, or an id
endpoints = endpoint { "&" endpoint }
endpoint = ( ident | ident_bars ) { "." ident } [ "." index ] [ ":" point ] # a capsule declares (SPEC 9)
index = digit+ # a 1-based pattern copy — drawing
# scope only (SPEC 15.4)
point = "top" | "bottom" | "left" | "right" # + corners, center, authored segments
# in a drawing scope (SPEC 15.2)
pen_item = call [ ":" ident ] # a draw: item — a pen call, optionally
# naming its product (point(): a station)
label_block = "[" { text | node } "]" # canonical labels — styleable text leaves;
# a node among them is a drawing
# annotation (SPEC 15.9)
values = value_group { "," value_group } # comma only between list items
value_group = value { value } # space-separated scalars
value = number | percent | string | hex | ident | css_var | call | group
call = ident "(" [ expr { "," expr } ] ")" # a call; each argument is an expr
group = "(" expr ")" # a math group — a number or point (SPEC 10.7)
css_var = "--" ident { "-" ident }
expr = { ident "=" expr ";" } value_expr [ "," value_expr ] # locals, then a value or a point
value_expr = operators, math library, a ternary, calls, groups — the grammar of SPEC 10.7
link_op = [ start_marker ] line [ end_marker ]
line = "-" | "--" | "---" | "~"
start_marker = "<" | ">" | "*" | "<>" | card_start
end_marker = "<" | ">" | "*" | "<>" | card_end # ER cardinality, either side (SPEC 9)
card_end = [ "o" | "+" ] ( "+" | "<" ) # [min][max] — min (o/+) hugs the line, max (+/<) outer
card_start = ( "+" | ">" ) [ "o" | "+" ] # the mirror — max (+/>) outer, min (o/+) hugs the line
ident = ( letter | "_" ) { letter | digit | "_" | "-" }
number = [ "+" | "-" ] ( digit+ [ "." digit+ ] | "." digit+ )
percent = number "%" # colour components only
hex = "#" hexdigit { hexdigit } # 3, 4, 6, or 8 hex digits
hexdigit = digit | "a"…"f" | "A"…"F"
string = '"' { char | escape } '"'
escape = "\" ( '"' | "\" | "n" | "t" )
comment = "//" { not-newline } newline
Single-pass LL(1). The stylesheet-first rule plus the bracket-and-bars vocabulary make
one token of lookahead enough — the first token of every statement tells its kind (a
leading capsule resolves node-vs-link on the single token after its closing bar,
SPEC 1):
in the stylesheet, |…| → a rule or (with an inner ::) a define, .name → a class rule,
#name → an id rule, --name : → a variable, ident : → a root declaration, ident =
or ident (…) = → a binding; after it, a
drawn statement is a node (|…|), text ("…"), or — when a bare ident is followed by
a link-op, &, or a . path — a link. A declaration ends with ; (its value may
span lines); a statement ends at a newline or ;.
The comma law rides values. value_group { "," value_group } is the whole
mechanism: a comma between repeated list items, a space between one item’s
components, pipelines (draw:, mirror:) one space-separated group
(SPEC 2). The parser preserves the shape; each list
reader enforces it with a targeted correction — a space-separated list errors as
`data` takes comma-separated values — `data: 9, 15, 24`.
Adjacency tells a .class from a path; a : tells a side. A space before the .
makes it a worn class (a .hot), no space an endpoint path (a.b); the first class is
spaced from the identity, the rest of the chain glues (.hot.loud); a : after an
endpoint forces a side (a:left), distinct from the declaration : by position.
Every layout reuses this grammar; drawing extends it, schematic adds one
relaxation. Charts and sequences add
no lexer or parser grammar — they are nodes, declarations, and children, distinguished
by type name and by the scope’s layout (SPEC 13, SPEC 14).
The drawing layout (SPEC 15) adds exactly seven things:
- the four
draw_optokens — glued, like every link op;||is resolved in the parser from two adjacent pipes at operator position only, so bars stay paired and selectors are untouched; - the one-ended relaxation — the right-hand endpoints may be omitted for
<-,*-,>-,(<), and must be for the unary-only(o); the binary(-)and||require both ends. (Meaning only in a schematic scope, the wire ops-->-<-<>-*may stand one-ended before a string or capsule — the label wire of SPEC 16.5.) One token of lookahead decides: after the op, an ident or a|opens an endpoint (bars: a capsule); a string,.,{,[, or end-of-statement is the tail; - the widened endpoint
pointset in drawing scope; - the numeric copy
indexin an endpoint path —plate.bolt.2; the lexer glues.+ digits in endpoint position only, so1.5in value position stays a number; - the
(-)dimension-familysel_unitat a stylesheet statement head — a leading(there is unambiguous, calls and groups appearing only in value position; - the annotation node among a
label_block’s labels — parsed everywhere, meaning only on a drawing’s dimensions and leaders: a core routed link’s[ ]stays text-only and a node there errors at resolve, SPEC 15.9; - the
pen_itemform inside adraw:value.
A call’s ( glues to its name; a free-standing (…) is a math group and a
free-standing (-), (o), or (<) an op (SPEC 2). The pen
calls, grid / radial, and hatch are call names, contextual before (
like rgb / repeat (SPEC 23).
23. Reserved Words
Because a type only ever appears in bars (|box|) and an id always wears a #, type
names are free as ids and ids are free as type names — |block#oval| is fine, and
block -> oval is two ordinary nodes. A small set of words stays reserved:
node,link, and the structural class namestext,marker,canvas,scene,cut: not instantiable types —nodeis the umbrella concept (write|block|for the bare box), links are drawn by operators and styled by|-|(|link|is an error), and a define may not take one of these (its generated.lini-<name>would collide with a built-in SVG class —|-|lowers to the reserved.lini-link).
The lini- prefix is reserved for generated names: desugar generates the type
classes (.lini-block, .lini-box, .lini-<define>) and mints ids
(#lini-topic-N — SPEC 12), so a user class or an authored id
may not begin lini-. User classes are emitted .lini-style-<name>. The
highlighter’s token classes (.lini-tok-<kind> — SPEC 20) take the
same prefix for the same reason: they land in a host’s document, where an
unprefixed name would collide.
The side names top, bottom, left, right are not reserved — they are
keywords only after an endpoint’s : (a:left), so a node may be named |box#left|.
Single quotes (') are reserved and are not strings.
Value keywords are contextual, not reserved as ids — flow, grid, stack, tree,
sequence, chart, pie, drawing, floorplan, schematic, row, column, radial,
bilateral, start, center, end, stretch, evenly, origin, none, auto,
orthogonal, natural, straight, px mean their keyword only after the property that
expects them. Every built-in type — the primitives (SPEC 7),
the templates (SPEC 8), and each layout’s own types
(SPEC 13–SPEC 16) — is protected from a
define shadowing it, free as an id.
Function names rgb, rgba, hsl, repeat are reserved only before ( — as are
hatch, grid / radial (in pattern:), and the pen calls (move, left, right,
up, down, line, angle, arc, curve, fillet, chamfer, circle, close)
inside a draw: value.
In link-operator position the marker glyphs + (one) and o (zero) are contextual —
they compose the ER cardinality marker (SPEC 9) and mean nothing elsewhere;
o is valid only next to a max glyph (-o<, +o-, …), so it never collides with an id or
the round measuring op (o) (delimited by parens). A leading + not followed by a digit
starts a cardinality op, mirroring -. The digit 0 is not part of any operator — a
round endpoint is marker-end: circle (a larger filled dot,
SPEC 7; the hollow ring exists only inside the ER glyphs),
never -o.
Inside a (…) expression (SPEC 10.7), pi, e, and the
sample parameters u / x are keywords, and the math-function names (sin, exp,
min, …) are reserved before ( — all contextual to the expression, free as ids
elsewhere. The backtick ` is unused and reserved.
24. Deferred
Named in the language, not built yet; the syntax is stable.
Every item below whose syntax is reachable today is an error — never silently accepted, never silently dropped (SPEC 21). That is what keeps each one a free option: a refusal can be relaxed in any later release, a quiet acceptance could not.
Core
- flow / grid callouts — the one-ended leader (
a <- "THRU") is a drawing’s (SPEC 15.7); the same shape in a flow or a schematic is an error. - balloon-capsule leaders — an inline capsule endpoint
(
p -> \|note\| "x") in a drawing, which never invents an endpoint (SPEC 15). - fractional /
frgrid tracks — a track is a size,auto, orrepeat(N[, size]); equal tracks arerepeat(N)(SPEC 12). - gradient fills on text — gradients fill nodes today (SPEC 10.3).
radiuson non-rect primitives (hex / diamond / slant / poly).- arbitrary numeric
font-weight(100–900 beyond the built 400–700 set) and kerning-aware measurement — the metrics ship without shaping (≈ 1 % on a proportional line, SPEC 5). - bidirectional text (RTL) — measurement and outlining walk a string in the
order it was written: no Unicode bidi reordering, no shaping, and no
dirknob to ask for either. A live figure hands its<text>to the renderer, which may reorder an Arabic or Hebrew run visually — against a box measured without having done so;--staticoutlines the glyphs in written order, so the run reads left-to-right and unjoined. There is nothing to refuse here (the syntax reserves no surface for it), and nothing built: a right-to-left scene is not supported today, and full bidi + shaping can land whole in any later release. - a solid (
fill-weight) icon variant (the built-in set is Phosphor duotone, behind a default-oniconscargo feature). aria-label.
Tables & entities
- arbitrary per-cell backgrounds in a
|table|— only the header and any|footer|cells carry a fill today; a body cell that needs one is a|block|(SPEC 8).
Sequences (SPEC 13) — fragments par (parallel, with an |and| separator),
break, critical, and ref; participant grouping; found / lost messages and
create / destroy lifelines; explicit activation spans; message auto-numbering;
dividers / delays (== / ...); and an |actor| stick-figure primitive (an actor is
|icon| today).
Charts (SPEC 14)
legend:placement / suppression (top·right·bottom·none) — the auto legend (≥ 2 entries) is built.- bands / marks in
radialcharts — a compile error today (SPEC 21);columnandroware built. - explicit per-axis tick text —
categories:covers the x axis today (the series’ per-datum text islabels:, SPEC 14.3). - gauge (a partial arc for one value); stacked areas (
bars: stackedextended to|area|); polar-area circular gridlines and a configurable radial start angle / direction (the polygon web and top-clockwise are the defaults). - per-slice explode, on-slice value / percent labels, and a centred total in a
donut hole; per-segment styling (a style list mirroring a segmented
fn:). - multi-ring pie / sunburst.
Drawings (SPEC 15)
- per-kind dimension selectors —
(o) { }/(<) { }; the family selector(-) { }reaches every dimension today (SPEC 4, SPEC 15.6), and a leader-specific selector under|-|is deferred too (YAGNI). explode:— scale every directed mate’s separation along its normal for exploded views; unmated overlaid children stay put (overlay composes one part, mates relate parts — only relationships explode). Balloons follow their parts.- authored-segment twins — a
mirror:copy of a:segmentis unaddressable (the name reads the drawn original; the unary mirrored readings cover the turned-profile cases; apattern:copy is addressed by index — SPEC 15.4). - routed links to authored anchors — the fixed-port routing contract is
built (ROUTING.md, Fixed ports — schematic pins ride it);
the flow / grid surface syntax
a -> b:portonto a sketch’s authored:segments remains deferred. - repeated-segment counting — one
:segmenton several corners auto-prefixing4× R3, aspattern:does for features; today, type it. - hole variants — counterbore and countersink (threads are built —
thread:, SPEC 15.3, SPEC 15.4). - deeper sourced-view nesting — a detail of a marker inside another detail / section is gated (SPEC 21); projection construction links between views are built (SPEC 15.8).
- angled break lines and a scope-level
break:on the\|drawing\|itself; abreak:station through acurve()(lines and arcs clip exactly today — move the stations off the cubic) andbreak:on non-sketch geometry (draw the profile with the pen). - the ASME text-in-a-broken-line diametral form and a horizontal-text knob (ISO aligned is the built-in; crossing halos are built — SPEC 15.7).
- an ambient
w/hbound to a node’s own size (circular against auto-sizing today — a named constant covers the workflow, SPEC 10.7). - balloon auto-numbering and auto-BOM from the scene’s parts.
\|mark\|/\|note\|in charts — data-coordinate placement (at:).
Floorplans (SPEC 15.11)
- computed room areas — a room polygon read off the wall topology, its area the smart label; today a room name and area are authored sheet text.
- openings on curved segments — straight runs only today (an arced wall itself is fine).
- a north arrow / scale bar type (a
\|sketch\|define covers it today). - more built-in fixtures (wardrobes, counter runs with an inset sink, door
casings / thresholds) — the
\|sketch\|-define parts library is the escape.
Schematics (SPEC 16)
- wire-seating — placing a series chain’s parts along the routed wire; today capsule chains hoist as adjacent flow siblings and satellites seat at pins.
- an ANSI symbol standard knob (scope-level, swapping the whole family; IEC is the built-in).
- logic gates; transformer (
T), relay (K), motor (M), speaker (LS), potentiometer (RV); crossing hop-over arcs; buses; pin electrical marks; hierarchical sheets; netlist semantics; a mid-wire tag riding a link’s[ ]at analong:fraction.
Beyond 1.0 — directions deliberately outside the release contract, listed so
they reserve no premature syntax: automatic graph / DAG layout (multi-parent,
cycles); a true ring-radial tree and forest (multi-root) trees
(SPEC 12); view-letter arrows on sheets (of: an arrow
marker composing “VIEW A (2:1)” — an arrow defines no capture, so it is title sugar
over a view’s smart label; construction links are built,
SPEC 15.8); imports / modules / namespaces
for shared themes and part libraries; animation; native PNG / WebP export.
(The blueprint theme — white linework on cyanotype blue, any diagram —
shipped as a --theme builtin, SPEC 20; a floorplan’s default
stays black-on-white.)
25. Examples
One worked example per family; the full per-feature gallery is the reference
implementation’s samples/ directory.
A scene — grid, defines, groups, nested links:
{
layout: grid; columns: repeat(3); gap: 40; padding: 20;
fill: --bg; clearance: 12; // clearance cascades to every link
|box| { radius: 4; } // round a touch less than the default 8
|-| { stroke: #666; } // every link's wire
--accent: #0a84ff;
.loud { stroke: red; stroke-width: 2; } // a link (or node) class — one vocabulary
|treat::box| { radius: 5; }
|alert::oval| { stroke: red; width: 36; height: 36; } // a circle
|room::group| {
gap: 8;
} [
|box#inlet| "Inlet"
|box#outlet| "Outlet"
inlet -> outlet "flows" // an internal link, per-instance
]
}
|oval#cat| "Cat" { cell: 1 1 }
|group#kitchen| "Kitchen" { cell: 2 1; gap: 20 } [
|treat#bowl| "Bowl of oats"
|box#water| "Water"
]
|room#closet| "Closet" { cell: 1 2 }
|room#fridge| "Fridge" { cell: 2 2 }
cat:right -> kitchen.bowl:left "watches"
kitchen.water -> closet .loud
closet.outlet -> fridge.inlet "restocks"
A tree and a mindmap (SPEC 12):
|column| "Org" { layout: tree; } [ // plain tree: neutral, orthogonal
|topic#ceo| "CEO" [
|topic#cto| "CTO" [ |topic| "Backend"; |topic| "Frontend" ]
|topic#coo| "COO" [ |topic| "Ops" ]
]
]
|mindmap#plan| "Launch" [ // preset: bilateral, natural curves,
|topic#product| "Product" [ // palette walk, depth ramp, 160 wrap
|topic| "MVP"; |topic| "Docs"
]
|topic#sales| "Sales" { side: left; } [ // overrides the ⌈n/2⌉ split
|topic| "Leads"
]
]
A sequence — a login flow:
{ layout: sequence }
|icon#user| "user" // an actor — any node is a participant
|box#browser| "Browser"
|box#api| "API"
|cyl#db| "Sessions"
user -> browser "click login"
browser -> api "POST /login"
api -> db "lookup"
db --> api "record"
|alt| "password ok" [ // a frame: its [ ] holds the branch's messages
api --> browser "200 + cookie"
browser --> user "dashboard"
|else| "wrong"
api --> browser "401"
]
|note| "rate-limited" { place: over api db }
Charts — bars, a formula with a band, and a pie:
|chart| "Cycle time (s)" { categories: "15 cm³", "30 cm³", "50 cm³" } [
|bars| "1.8 kW" { data: 9, 15, 24; fill: --sky }
|bars| "2.3 kW" { data: 7, 13, 20; fill: --amber }
]
|chart| "Injection profile" [
|axis#bar| "Pressure (bar)" { side: left; range: 0 1100 }
|axis#x| "Speed (mm/s)" { side: bottom; range: 0 133 }
|area| "Pressure" { axis: bar; fn: (x <= 93 ? 1000 : 1000 - 319*((x-93)/40)); fill: --teal }
|band| { range: 93 133; axis: x; fill: --red }
|mark| "1000 bar @ 93" { at: 93; axis: x; color: --muted }
]
|pie| "Spend" { hole: 0.5 } [
|slice| "Ads" { value: 40 }
|slice| "SEO" { value: 30 }
|slice| "Direct" { value: 30 }
]
A radar (radial chart) and labelled scatter:
|chart| "Profiles" { direction: radial; categories: "Speed", "Range", "Armor", "Cost", "Stealth" } [
|axis| { range: 0 5 }
|line| "Scout" { data: 5, 4, 2, 3, 5 }
|area| "Cruiser" { data: 3, 3, 5, 4, 2; fill: --teal }
]
|chart| "Effort vs. score" [
|axis| "tokens (k)" { side: bottom }
|axis| "score %" { side: left }
|line| "GLM-5.2" { data: 35 63, 42 72, 84 75; labels: "Base", "High", "Max"; marker: circle; tooltip: always }
]
A drawing — a sheeted screw, two views sharing an axis (SPEC 15):
|page| { sheet: a5 landscape; gap: 50; align: origin; } [ // landscape → direction: row
// the ISO sheet: frame, zones, marks — views share their axes datum-to-datum
|drawing#side| "DIN 912 — M8 × 40" { scale: 1.5; } [
|sketch#screw| {
draw: move(0, 0) up(6.5) chamfer(0.8) right(8):head down(2.5):k right(12)
point():v right(28):m8 chamfer(1) down(4);
revolve: x-axis; // a turned part
thread: m8 1.25; // the threaded run
} [
|hidden#socket| { // the hex socket, dashed
draw: move(0, 3) right(4) line(3, -3);
mirror: x-axis;
}
]
screw:head (o) { side: left; } // → ⌀13
screw:left (-) screw:k { side: bottom; } // → 8 — K, the head
screw:k (-) screw:right { side: bottom; } // → 40 — L, under the head
screw:v (-) screw:right { side: top; } // → 28 — the thread length
screw:m8 <- { side: top; } // → M8×1.25 — composed by the thread
]
|drawing#end| { scale: 1.5; } [
|oval#od| { width: 13; height: 13; }
|oval| { width: 11.4; height: 11.4; } // the head, end-on
|hex#socket| { width: 7; height: 6; }
socket:left (-) socket:right // the socket, visible here
]
|title-block| {
title: "Socket cap screw"; drawing-number: "DIN 912 — M8 × 40";
revision: "A"; sheet-number: "1/1"; date: "2026-07-08"; author: "AM";
}
]
A floorplan — a studio flat (SPEC 15.11):
{ layout: floorplan; unit: m; scale: 0.02 } // 1 : 50 — thickness defaults 200 mm
|wall#outer| {
draw: move(0, 0) right(7.2):north down(4.8):east left(7.2):south close():west;
} [
|window#w1| { on: north; at: 2.7; width: 1.6 }
|door#entry| { on: south; at: 1.05; width: 0.95; swing: right }
// 'south' was drawn leftward, so 'at' counts from its east end and the pen's
// left is the outside — 'right' opens the door into the flat
]
|partition#bathwall| {
draw: move(4.9, 0) down(2.3):face right(2.3):side; // the bathroom corner
} [
|door| { on: side; at: 0.15; width: 0.8 } // 900 mm and 'hinge: start' by default
]
|bed| { rotate: 90; translate: 1.15 1.05 }
|sofa| { symbol: two; rotate: 90; translate: 0.6 3.4 }
|dining| { symbol: round; translate: 3.3 3.3 }
|rect#counter| { width: 0.6; height: 1.4; translate: 6.8 3.2;
fill: --bg; stroke: --stroke-dark; stroke-width: 1 }
|appliance| "F" { symbol: fridge; translate: 6.8 2.8 }
|bath| { symbol: shower; translate: 6.6 0.6 }
|bath| { symbol: toilet; rotate: 90; translate: 5.35 0.5 }
|bath| { symbol: sink; rotate: 90; translate: 6.85 1.6 }
"STUDIO 27 m²" { translate: 2.2 2.2 }
// Every dimension reads a clear span, face to face — what a listing plan
// publishes. 'face' runs south, so its 'out' face is the living side's.
outer:west-in (-) bathwall:face-out { side: top } // → 4.75 — the living space
bathwall:face-in (-) outer:east-in { side: top } // → 2.15 — the bathroom
outer:west-in (-) outer:east-in { side: top } // → 7 — the shell, clear
outer:north-in (-) outer:south-in { side: right } // → 4.6