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.

Coffee Code Bug Ship tests pass
{
  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:

FamilyThe layout reads…Tour
Diagramsboxes and the wires between themDiagrams, Links
Trees & mindmapsnesting as hierarchyTrees & mindmaps
Tables & entitiesrows and columns; a titled field listTables & entities
Sequencessource order as timeSequences
Chartsdata as bars, lines, areas, bubbles and slicesCharts
Drawingsgeometry it can measure — the dimensions read themselvesDrawings
Floor planswalls, openings and fixtures at true sizeFloor plans
Schematicsparts on a lattice, wires on pinsSchematics

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
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
Visitor API Postgres

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 named api with 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
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
Web API Cache Postgres Store

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"
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: 80 is 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:

FormMeans
|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.
.nameA class, defined once and worn after the label.
a -> bA 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:

WriteGetTour
layout: flow (default) · gridboxes in a row, a column or a gridDiagrams
layout: tree · |mindmap|a hierarchy from nesting aloneTrees & mindmaps
|table| · |entity|ruled grids and ER cardsTables & entities
layout: sequenceparticipants, lifelines and messages on a time axisSequences
layout: chart · piebars, lines, areas, dots, slicesCharts
layout: drawinga dimensioned engineering sheetDrawings
layout: floorplanwalls, openings and fixturesFloor plans
layout: schematicparts, pins and routed wiresSchematics

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
Visitor API Worker Postgres

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

block block box rect oval hex slant cyl diamond poly line path icon sketch badge 3 + badge
{
  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"
UI Cache Browser API Postgres Server fetch 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; }
Header Nav Main Side Footer

layout: grid needs columns: — fixed sizes, auto, or repeat(N) — and fills left to right, wrapping at the count.

  • span: cols rows covers several tracks.
  • cell: col row places 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; }
Card 3 draft Plain

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"
]
short a longer one align: start short a longer one align: stretch low justify: end

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

a b a b a b a b a b a b a b a b -> <-> --> ---> ~> *-* -<> -
{
  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:

LineMarker
-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; } ]
Client Server Store request read write rows

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
LB app-1 app-2 app-3 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 & c fans out.
  • a & b & c -> db fans 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"
a b c skip 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
]
a b c d orthogonal a b c d natural a b c d straight

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.

{
  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 b c cold 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"
  ]
]
Chief Executive Engineering Platform Product Operations Support 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"
  ]
]
Request Triage Approve Reject Escalate Auto-close Duplicate

Three directions, and each one is a different document:

  • column (the default) grows down — an org chart.
  • row grows right — an outline, like this one.
  • bilateral grows 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"
  ]
]
Launch Product MVP scope Docs and aplayground Marketing Site Launch post Sales Leads Pricing page Team Hiring plan

|mindmap| is the visible root topic with a whole scene preset folded in — none of the colour above is written:

  • direction: bilateral, and routing: natural for 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
Plan Build Compiler Formatter Ship this quarter Binary Crate Docs The tour Reference Test Conformance Snapshots

Everything on that figure is reachable from the stylesheet:

  • A tier at a time. Every topic wears a generated .lini-level-N class, 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: .now flares 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"
]
Plan Seats Price Free 1 $0 Team 10 $120 Scale 50 $480 Plans

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.

{
  |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"
]
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 Total 36.4M 3 regions Edge 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, as Degraded does.
  • 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"
Users PK id int email varchar name varchar Orders PK id int FK user_id int total decimal 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:

OpReadsOpReads
-+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
Author id int name varchar Post id int author_id int title varchar PostTag post_id int tag_id int Tag id int name varchar writes

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; }
]
Plain text stands on its own. Styled in place. Two lines,with a break. A heading and a line of body text under it.

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; }
Ship the releasenotes to everycustomer whoasked Rotate the signingkeys before theaudit windowopens Archivedashboardsnobody openedthis quarter

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" ]
3 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:

  • fill is the soft body, stroke the line.
  • fill: none makes it a single-tone line icon, as cloud is 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"
]
128 ALERTS 24 KEYS ! 1.4k 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"
user server login 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"
Customer Storefront Orders place order reserve stock reserved 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"
Storefront Payments charge $42 verify card receipt 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 -> a a 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; }
Storefront Payments Orders loop [each item] one round trip per line item charge $42 receipt reserve stock in stock

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"
]
Customer Storefront Payments alt [accepted] [declined] submit card authorize approved receipt refused 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; }
]
Mon: 4 Tue: 9 Wed: 6 Thu: 12 Fri: 7 0 5 10 15 Mon Tue Wed Thu Fri Deploys per weekday Mon: 4 Tue: 9 Wed: 6 Thu: 12 Fri: 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; }
]
Q1 · NA: 40 Q1 · EU: 30 Q1 · APAC: 20 Q2 · NA: 45 Q2 · EU: 35 Q2 · APAC: 25 Q3 · NA: 50 Q3 · EU: 33 Q3 · APAC: 30 Q4 · NA: 60 Q4 · EU: 40 Q4 · APAC: 38 0 50 100 150 Q1 Q2 Q3 Q4 Revenue by region ($k) NA EU APAC Q1 · NA: 40 Q1 · EU: 30 Q1 · APAC: 20 Q2 · NA: 45 Q2 · EU: 35 Q2 · APAC: 25 Q3 · NA: 50 Q3 · EU: 33 Q3 · APAC: 30 Q4 · NA: 60 Q4 · EU: 40 Q4 · APAC: 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; }
]
before: p50: 40 before: p90: 180 before: p99: 420 after: p50: 30 after: p90: 120 after: p99: 260 budget 0 100 200 300 400 500 p50 p90 p99 Latency (ms) before after before: p50: 40 before: p90: 180 before: p99: 420 after: p50: 30 after: p90: 120 after: p99: 260

|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; }
]
autoscale ceiling 0 200 400 600 800 0 4 8 12 16 20 24 Hour of day (UTC) over ceiling Edge requests per second

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; }
]
GLM-5.2: 9, 52 GLM-5.2: 16, 63 GLM-5.2: 27, 72 GLM-5.2: 48, 79 GLM-5.2: 88, 83 GLM-5.1: 5, 47 GLM-5.1: 12, 54 GLM-5.1: 20, 58 GLM-5.1: 29, 62 GLM-5.1: 40, 65 GLM-5.1: 54, 68 GLM-5.1: 68, 69 GLM-5.1: 86, 71 40 50 60 70 80 90 eval score (%) 20 40 60 80 tokens per task (k) Score vs. thinking budget GLM-5.2 GLM-5.1 off low mid high max GLM-5.2: 9, 52 GLM-5.2: 16, 63 GLM-5.2: 27, 72 GLM-5.2: 48, 79 GLM-5.2: 88, 83 GLM-5.1: 5, 47 GLM-5.1: 12, 54 GLM-5.1: 20, 58 GLM-5.1: 29, 62 GLM-5.1: 40, 65 GLM-5.1: 54, 68 GLM-5.1: 68, 69 GLM-5.1: 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; }
]
Jan 5 2026, 240 Jan 12 2026, 232 Jan 19 2026, 251 Jan 26 2026, 384 Feb 2 2026, 412 Feb 9 2026, 301 Feb 16 2026, 262 Feb 23 2026, 244 Mar 2 2026, 231 Mar 9 2026, 225 Mar 16 2026, 219 rollback 0 100 200 300 400 500 Jan 5 Jan 19 Feb 2 Feb 16 Mar 2 Mar 16 incident p95 latency, eu-west (ms) Jan 5 2026, 240 Jan 12 2026, 232 Jan 19 2026, 251 Jan 26 2026, 384 Feb 2 2026, 412 Feb 9 2026, 301 Feb 16 2026, 262 Feb 23 2026, 244 Mar 2 2026, 231 Mar 9 2026, 225 Mar 16 2026, 219

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; }
]
billing: 4 auth: 7 cart: 9 media: 40 search: 26 feed: 17 0 50 100 150 200 250 p99 latency (ms) 0 1000 2000 3000 4000 requests/s Cost by service ($k / month) billing auth cart media search feed billing: 4 auth: 7 cart: 9 media: 40 search: 26 feed: 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; }
]
Speed Range Armour Cost Stealth 1 2 3 4 5 Profiles Scout Cruiser

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; }
]
Ads: 40 (40%) SEO: 25 (25%) Email: 20 (20%) Direct: 15 (15%) Spend by channel Ads SEO Email Direct Ads: 40 (40%) SEO: 25 (25%) Email: 20 (20%) Direct: 15 (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; }
120±0.2 70

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.2 appends ±0.2 to 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
80 R6 45°

|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"
25 115 45 3× ⌀8 H7 A 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; }
]
80 ⌀30 h6 M18×1.5 MAIN SHAFT

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.5 dresses 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";
  }
]
60 ⌀18 LOCATING PIN ⌀18 Title Locating pin Dwg No. LN-0031 Rev A Sheet 1/1 1 2 3 4 1 2 3 4 A B A B

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 for symbol: 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, because south was drawn leftward.
  • swing: names the left of the pen’s travel — which on that same leftward run is the outside. right is 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; }
F STUDIO 27 m²

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; }
]
2.65 4.05 6.8 4.4 UNIT 3B

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

WALLS solid · 200 |partition| · 100 thickness: 0.4 fill: --bg hatch(45) section mitred corner arc · concentric DOORS D1 start · left start · right end · left end · right symbol: double symbol: sliding |partition| · schedule tag WINDOWS · STAIRS 1200 default width: 2 in a |partition| two on one run steps: 4 steps: 8 BEDS queen king double single SOFAS three two one corner stool DINING six four round BATH tub shower toilet sink double-sink APPLIANCES F W DW stove fridge washer dishwasher
{
  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

D1 D2 W1 D3 D4 F DW W/D LIVING / DINING 23.9 m² KITCHEN BEDROOM 12.8 m² HALL BATH 7.3 m² BALCONY N 9.4 4.45 4.85 3.85 2.65 6.6 UNIT 12A — ONE BEDROOM · 65 m²
{
  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"
vin 3 vout 2 gnd 1 AMS1117-3.3 U1 1 2 3V3 OUT J1 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|
vin 3 vout 2 gnd 1 AMS1117-3.3 U1 10u C1 22u C2

|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"
vs 18 step 4 diag 11 gnd 1 TMC2300 U7 5V STEP 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:

  • -> draws STEP in a right-pointing flag.
  • - leaves DIAG untagged — and a plain label is a run rather than a stop, so its box is the stretch of trace it names. That is why DIAG is 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|
vin 3 vout 2 gnd 1 AMS1117-3.3 U1 1 2 3V3 OUT J1 1k R1 PWR D1 5V 3V3

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

Passives 10k R1 10k R2 10k R3 100n C1 47u C2 4u7 L1 600R FB1 1A F1 16M Y1 Diodes 1N4148 D1 5V1 D2 SS34 D3 SMAJ24 D4 green D5 Transistors BC847 Q1 BC857 Q2 2N7002 Q3 DMP2 Q4 TLV9061 U1 BC847 Q5 Sources & switches 5V V1 230V V2 20m I1 20m I2 3V BT1 12V BT2 ON SW1 RST SW2 Electromechanical 5V fan M1 85dB BZ1 VBUS TP1 Net tags NRST TX RX SDA CLK VBUS RF Grounds
// 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-axis on 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; }
8 * 2 sqrt(2) * 60 clamp

A ( … ) group is folded away when the file compiles. Inside it you get:

  • Arithmetic+ - * / ^, and comparisons.
  • A ternarycond ? a : b.
  • The math librarysqrt, 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); }
step(0) step(1) step(2) 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"
Ingest Queue Store raw 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"
Ingest Queue Store raw 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"
Ingest Queue Store raw 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:

  1. Type rules — |box| { }
  2. Descendant rules — |group| |box| { }
  3. Classes.stage { }
  4. The id rule — #store { }
  5. 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
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; }
gradient hatch shadow multiple

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, …) and radial-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.
  • shadow drops a soft shadow.
  • multiple draws 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
Ingest Store

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 dark pins a palette — light, high-contrast and blueprint too, or a CSS file of your own --lini-* overrides.
  • lini theme NAME prints 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.

figure.lini lini SVG HTML compile fmt · desugar
{
  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:

  • --static inlines 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-font keeps 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
cat dog bird

That’s a complete diagram: three boxes, two links. Lini fills in the rest.

FormMeans
|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 blockkey: value; declarations.
[ … ]A content list — a node’s children.
.nameA class — define it (.hot { … }), wear it (|box| .hot).
#nameAn id — declare it (|box#cat|), select it (#cat { … }), reference it bare (cat -> b).
--nameA themeable variable (fill: --accent).
a -> bA 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 -> dog adds |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
Server Client requests

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:

PartHoldsDrawn?
stylesheetone { } block: scene config (incl. link & routing defaults), rules, classes, definesno — it styles
canvasinstances — boxes (|type#id|) and text ("…")yes
linksa -> b connectionsyes

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

PropertyValue
Extension.lini
EncodingUTF-8 (BOM ignored)
Line endingsLF or CRLF (normalized on read)
Comments// … to end of line. No block comments.
Statement endA 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:

FormWhitespace 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.
#idInside 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:sideA : 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.childNo space — an endpoint path into a child (kitchen.bowl).
--nameA 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 transformdraw: 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 gradientgradient(…), 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:

ItemFormMeans
Scene configlayout: 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)
Bindingmy_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"
]
FormEffect
|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):

LabelMeans
no string at allnothing — 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" onbecomes
|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 linka label along the route (SPEC 9)
a |chart| / series / |axis| / participant / frameits 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
Bowl Kitchen 3

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’s layout.
  • 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 at max-widthSPEC 5); the box sizes to the widest line, with a font-size × 1.2 leading between lines (plus any line-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:

  1. 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.)
  2. Descendant rules|table| |box| { }, .sidebar |box| { }, matched against the ancestor chain.
  3. Class rules.hot { }, worn via |box| .hot on the node (a text leaf wears them the same way — "x" .hot, SPEC 3).
  4. Id rule#hero { }, the node’s own id.
  5. 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.

  1. Centre origin. Every bbox is centred at the parent’s origin by default.
  2. Source order = render order; later draws on top, with pinned children above the in-flow ones. layer: N overrides; ties break by source order.
  3. Strokes count toward the bbox — width: 100 height: 50 stroke-width: 4 → 104×54. Only painted strokes: stroke: none paints nothing and counts nothing, whatever stroke-width says — so a bare |block| (which keeps stroke-width: 2 invisibly, SPEC 7) truly sizes to its content.
  4. |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, a pattern:’s seed, a |drawing|’s datum — SPEC 12, SPEC 15.1.)
  5. 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
centercentre on the parent’s centre
top · bottom · left · rightflush against that parent edge
top left · top right · bottom left · bottom rightwith 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 alikestroke 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 colorinherits: 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:

  • Bakedletter-spacing, line-spacing, font-size, and text-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-transform into the text itself, measured as it will draw — never emitted as a style (SPEC 1). letter-spacing / line-spacing default to 0, so text is unaffected until set.
  • Live CSSfont-style, text-decoration, text-shadow — does not touch layout: it rides the class / <g> / .lini rule 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-boxSPEC 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.

PrimitiveRequiredNotes
|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|pathRaw SVG path. Native top-left coords.
|line|points2+ points. Markers via marker*:.
|icon|symbolA 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, heightA 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|drawA 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)

PropertyFormsEffect
stroke-stylesolid / dashed / dotted / center / phantomStroke 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.
multipleN / dx dyDraw one offset duplicate behind the node — the “several of these” reading. Scalar N = N -N.
rotateN degreesRotate around the bbox centre (SPEC 5).
shadowN / dx dy / dx dy blur / dx dy blur colorDrop shadow via SVG <filter>. Scalar N = offset N N, blur N; tint defaults to --lini-shadow-color.
PropertyEffect
marker: XBoth ends.
marker-start: XStart end (link source).
marker-end: XEnd 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 setcrow (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
3

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.

TemplateBaseDefaultsFor
|box||block|fill: --fill; stroke: --stroke; stroke-width: 2; radius: 8; padding: 20The default node — a rounded, framed card.
|rect||box|radius: 0A sharp-cornered box.
|group||block|stroke: --group-stroke; stroke-style: dashed; stroke-width: 1; fill: --group-fill; radius: 8; padding: 20Dashed 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-weightA title, pinned just above the group’s top-left corner.
|footnote||caption|pin: bottom; translate: 0 19; color: --footer-colorA 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: semiboldA 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: normalCorner pill — nudged out over the top-right corner, grows nothing.
|row||block|direction: rowFrameless wrapper — children in a row.
|column||block|direction: columnFrameless wrapper — children in a column.
|grid||block|layout: gridFrameless grid (needs columns).
|sign||icon|width: 64; height: 64; padding: 4; stroke-width: 2; fit: containA 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: 1Ruled grid (see below).
|cell||block|padding: 4 8A table cell — a frameless |block| carrying the text-to-gutter inset (the mechanism: Tables, below).
|header||cell|fill: --header-fill; font-weight: semiboldA header cell — a filled, semibold band (a |table|’s first row; an |entity|’s title spans them).
|footer||cell|color: --footer-colorA footer cell — muted text; opt-in on the last row.
|entity||table|columns: auto, autoAn 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 14A 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: 1A 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: 1An item balloon — the numbered circle an assembly leaders to a part (SPEC 15.8).
|stack||block|layout: stack; padding: 0The datum container (SPEC 12) — children place their origin on it instead of flowing.
|drawing||block|layout: drawing; padding: 0An engineering drawing — geometry on a datum, measured annotations; scale: is its drafting ratio, default 1 (SPEC 15).
|hole||oval|fill: --bg; stroke: --stroke-darkwidth: required, the diameterA 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: nonewidth: required, the diameterThe 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 sideAn 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 chromeA 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: noneThe 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: nonewidth: required, the region diameterThe 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: 1The 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: 1The 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: 1The 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: --bgsheet: a4 unless sized; direction by orientationAn 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: 1The 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: 2A 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-colorA title-block field’s caption, over its value — quieter in size, weight, and tone (SPEC 15.8).
|zone||block|font-size: 11; color: --stroke-lightA 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: schematicA 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 NAn empty slot between a component’s pins (SPEC 16.2).
|label||block|shape: plain; font-size: 11; color: --label-inkThe net tag — text, a symbol (gnd, power, …), or both; its own terminal (SPEC 16.4).
|junction||oval|fill: --wire; stroke: none — generated chromeThe 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 hiddenThe 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: variantsThe one/two/three-terminal parts; the type is the ref family (SPEC 16.3).
|gnd| / |nc||label|symbol: gnd / symbol: ncBuilt-in ground / no-connect defines (SPEC 16.4).
|floorplan||drawing|layout: floorplanAn 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: nonedraw: 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: leftA 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 chromeWhat 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 wrapmax-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"
]
Fruit Quantity Notes 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" ]
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:

ForWrite
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:

PartTokens
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).

OpMarkers / 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 mirrora +-< b is one-to-many, a >o-o< b zero-or-many both ways. The six relations, shown end-side:

OpRelation
-+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, crowSPEC 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
a b hi

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 [ ]:

PropertyNotes
alongA 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
catroot node cat
kitchen.counter.bowlexactly that path
kitchen.counter.bowl:leftthe 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.

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"
Inlet Outlet Garden Inlet Outlet Kitchen carries flows flows

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 layoutlayout 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:

TierExampleJob
wash--teal-washpalest — card and section backgrounds (a faint tint; a deep, muted surface in dark mode)
soft--teal-softa gentle, lighter pastel fill
base--tealthe everyday pastel — the bare name is the easy path
deep--teal-deepthe strong tone — borders and strokes
ink--teal-inkdeepest 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.

FormResult
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:

FormResult
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 Ledgerevery 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 ternary cond ? 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, called name(args). (Colour / track builders like rgb / repeat make typed values, so they live in value position, never inside math.)
  • Constants pi, e; scientific notation 1e6, 1.32e-6; the sample parameters u (geometry, below) and chart x; and your bound names, read bare (below). A bare name resolves: locals → the ambient (u / x) → pi / e → your bindings.
  • Localsname = expr; binds for the rest of the group; the final expression is the value (no keyword, no return). = 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 uu 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:EngineArranges its children asWiring strategyLowers its subtree?
flow (default)1D flexboxes / text in a row or columnorthogonal routerno — arranges in place
grid2D gridboxes / text in tracksorthogonal routerno — arranges in place
stackdatumevery child’s origin on one datum (SPEC 12)orthogonal routerno — arranges in place
treerooted hierarchytopics in generations (SPEC 12)router (orthogonal / natural)no — arranges in place
sequencetime axisparticipants + messages + frames + notes (SPEC 13)time-rows → the straight strategyyes
chartdata planeseries + axes + bands + marks (SPEC 14)layout-time data→pixelsyes
piepart-to-wholeslices (SPEC 14)layout-time value→angleyes
drawingdatum / geometrygeometry + annotations + mates (SPEC 15)layout-time dims / leadersyes
floorplanthe drawing engine, architectural dialectwalls + openings + fixtures + annotations (SPEC 15.11)layout-time dims / leadersyes
schematiccircuit sheetanchors on tracks + satellites at pins (SPEC 16)orthogonal router, fixed portsno — 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):

  1. 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.

  2. The wiring strategy realises a scope’s links. flow / grid / tree — and schematic, whose wires land on fixed ports (SPEC 16.5) — hand their links to the router (SPEC 9, ROUTING.md); sequence fixes each message’s geometry (column x, row y) and hands it to the straight strategy; a drawing — and its floorplan dialect (SPEC 15.11) — lowers each link to a dimension, leader, or mate (SPEC 15); chart / pie have no links. One scope, one strategy — set by the scope’s layout (with routing: selecting orthogonal, natural, or straight for 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). A sequence message is thus the one place a link’s order is its geometry, not a routing problem.

  3. A layout-owning engine lowers to primitives in the layout phase. flow / grid arrange their children where they sit. sequence / chart / pie / drawing (floorplan included) 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.

Valuejustify (main axis)align (cross axis)
start / center / endpack at the edge / centre / oppositealign each child to the edge / centre / opposite
stretchfills children to span the main axiseach child’s box fills the cross axis
evenlyequal 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:

PropertyNotes
columnsRequired. 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.
rowsOptional. Same form. A floor, not a cap: extra children flow into implicit auto rows. Omitted → all rows implicit, count ⌈children / columns⌉.
cellA box child’s placement column row, 1-indexed (cell: 2 1).
spanA 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:GrowthThe look
column (default)down from the rootorg chart
rowrightward from the rootlogic tree / outline
bilateralboth sides, horizontallymindmap

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 parentceo:bottom - ceo.cto:top & ceo.coo:top — written in the scope that contains the parent, with the direction’s forced sides (column: bottomtop; row: rightleft; 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):

ChildIs aDrawn
a box (|box|, |cyl|, |icon|, …)participanta header at the top + a lifeline down
a link (a -> b)messagea time-row arrow between two lifelines
|loop| / |opt| / |alt|framea labelled rectangle around a span of messages
|else|separatora guarded compartment divider inside an |alt|
|note|notea 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"
user server login 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.

WriteMeans
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):

FrameMeans
|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.)

TypeDefaults 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 layoutlayout: 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

LayoutTemplateEncodesChildren
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):

PropertyLayoutValueDefault
directionchartcolumn · row · radialcolumn
barschartgrouped · stacked · overlaygrouped
categorieschartquoted-string list — the x-axis (or spoke) labelsindices 1…N
sampleschartinteger — fn: sample count24
holepie0 ≤ n < 1 — inner-radius fraction (a donut)0
legendbothtop · right · bottom · none ⌛ (SPEC 24) — writing it is an error until the reader landsauto (shown when ≥ 2 entries) — built
tooltipbothnone · hover · auto · always (14.8)auto
gapbothnumber — clear space between the plot and the title / legend outside it15
clearancebothnumber — 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):

SeriesLayoutDrawsLowers toPaint
|line|charta polyline through the data (a closed loop when radial)|line| / |path|stroke, stroke-width, stroke-style
|area|charta line filled to a baseline|poly| / |path| + |line|fill, stroke, baseline
|bars|chartone bar per datum (a wedge when radial)one |rect| / |poly| eachfill, stroke, radius
|dots|chartone marker per datumone |oval| / marker eachfill, stroke, marker
|bubble|chartone bubble at a point, sized by value:one |oval|fill, stroke
|slice|pieone wedgeone |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:

SourceSyntaxMeaning
categoricaldata: 9, 15, 24, 18, 30scalar items → one value per category
pointsdata: 0 225, 60 225, 118 221x y items → points (numeric x; scatter)
formulafn: (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) }
]
0 10 20 30 0 0.2 0.4 0.6 0.8 1 Steel Aluminum

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.

PropertyValueNotes
sidebottom · left · right · topcartesian only; several on one side stack outward in source order
rangea b (each end a number, a quoted date, or auto)the data window — and crop, and reverse (below)
scalelinear · log · timelog 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 / ticksnumber / list · calendar (time)tick spacing, or explicit ticks; omitted → nice ticks
formatfamily + args (SPEC 17)tick-value presentation; inherits from the chart
unit"%"a quoted suffix appended to tick labels (and tooltips)
gridlinesnone · colourthis axis’s gridlines: none, or a colour (a colour turns them on)
stroke / color / font-sizecorestroke 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 (ab), 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 literalsrange:, 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:

FormDraws
|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 }
]
Ads: 40 (40%) SEO: 30 (30%) Direct: 30 (30%) Spend Ads SEO Direct Ads: 40 (40%) SEO: 30 (30%) Direct: 30 (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 hoverFor
nonea clean static plot, no labels
hovercard + <title>labels on demand
auto (default)where it fits, else falls to hovercard + <title>the printable default
alwaysevery label, forcedcard + <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:

ChildIsDrawn
a box (|sketch|, |rect|, |oval|, |hole|, …)geometry — a part or a featureits outline and fill, at the shared datum
a link with a measuring op — (-) (o) (<) — or a leader op — <- *- >-an annotationextension lines, arrows, text (15.6, 15.7)
a link with ||a mate — or, with a sheet-content end, a seatnothing — it positions a part or an annotation (15.5)
any other two-ended link (->, <->, -->, -*, …)a straight annotation arrowone segment, markers per the op
"…", |note|, |balloon|, |table|, |surface-finish|, |feature-control|, |datum|, …sheet contentper 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: and along: have no role on them; clearance: reads as a dimension’s stand-off minimum (15.6).
  • No auto-create. Unlike a diagram (cat -> dog invents 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 :segment a \|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 everywhereDrawing-scope only
|sketch| + draw: / mirror: / revolve: / thread: / break:; pattern:; scale:; hatch() fills; stroke-style: center / phantom; |note| / |balloon| / |hidden|; the |page| sheetthe 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: 2 reads 2 : 1, scale: 0.5 reads 1 : 2, and the composed section / detail / view titles read it directly (15.8). Magnitude is scale:’s job — a 5 m beam on an A4 is scale: 0.02 (1 : 50), never a resolution fudge.
  • unit: — the physical size of one drawing unit: mm (a drawing’s default), cm, m, in, or px — one unit is one pixel, the non-physical case, and a plain stack’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: N on 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. Under unit: px there are no millimetres to convert, so only the identity density: 1 applies and any other value warns (SPEC 21).

The engine’s pixels-per-unit is always derivedratio × 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 300measured 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, center its centre. Corners glue vertical word first, matching pin’s vocabulary (pin: top left:top-left); the reversed order errors with a did-you-mean. Corners and :center are 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 in draw: (15.3) — declared in the pen, selected on an endpoint, the same declare / select symmetry as #id. Built-in names win (:left cannot be authored); an unknown segment errors with suggestions; mirror: copies of a segment are not addressable (SPEC 24) — a pattern: 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).

CallDoes
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 > 0deg > 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:

OnNamesExample
a drawing callthat call’s drawn segment: an edge, an arc, a bevel, a circle, a close() seamright(50):neck, fillet(3):r1
point()the pen’s current pointright(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:

ItemAxis (through the node’s origin)Gives
x-axisthe horizontal axis (y = 0)top ↔ bottom symmetry
y-axisthe vertical axis (x = 0)left ↔ right symmetry
a number 45the 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 adjoining chamfer()’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:

FormCopies
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 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 || b and b || a are 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:top errors), 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 place gap goes below zero). Point anchors (center, a freestanding name) make the points coincide — the bare a || b is the origin-to-origin case — and have no normal, so gap: 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 own translate: applies after — the universal post-placement nudge, here a lateral slide along the face. A pin: 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:

EndsReadsWho moves
geometry || geometrya matethe grounding walk above
annotation || geometrya seatthe annotation, always — either operand order
annotation || annotationerrorseat 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, so rotate: -90 stands 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.
AnnotationDefault seat anchor
|surface-finish|the symbol’s tip — the vee stands on the face (15.9)
everything else — |feature-control|, |datum|, |note|, |balloon|, a bundlethe 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).

WriteReadsRenders
a:left (-) b:righta linear spanextension lines, arrows, 25
a:left (-) b (-) ca chaineach hop its own dim, one shared row
pin (o)a round featurethe ⌀ line across the circle — both arrows on the rims — 2× ⌀10
hole:top (o)a round feature, side-anchoredthe diametral line through the circle
bore:top (o)any node, side-anchoredthe span to the opposite side, ⌀-read — ⌀16
body:neck (o)a revolved-profile segmentthe station’s span across the axis — ⌀28
body:r1 (o)a named arca leader — R3
body:flank (<) body:basetwo line-like anchorsthe angle arc — 40°
body:taper (<)a mirrored- / revolved-profile segmentthe 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 binarya (-) 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-anchoredhole (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:

SourceOwnsExample
the opthe glyph(o) / R · (<)° · tol:± (linear (-) adds none — a plain length)
the geometrythe number10
the labelthe wordstwo-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, appendedtol: 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 · a mirrored pair of holes

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
160 ⌀28 h6 R3

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:

OpTip on the featureFor
<-arrowan edge or outline
*-dota leader landing within an outline — a face, a region
>-datum trianglea 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 gains datum (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|’s datums: 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 stands note-offset clear 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’s translate nudges 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:

ProducerGenerates
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 geometryits |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: N places it: the plane runs perpendicular to an axis at station N, 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 | down turns the arrows — default right for a vertical plane, down for 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 with translate: 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 }, a a |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 own scale: read directly (1:1 at the default, 2:1 enlarged, 1:1.5 reduced, ≤ 2 dp — 15.1).
  • The detail view|drawing#det| { of: c }, c a |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 magnifying scale: 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 (ae; a portrait, be 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 propertiestitle, 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: 115.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:DrawsMeans
basic (default)the bare veeany process
machinedvee + barmaterial removal required
prohibitedvee + circleremoval 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):

GroupCharacteristicsdatums:
formstraightness · flatness · circularity · cylindricityforbidden
profileprofile-line · profile-surfaceoptional
orientationangularity · perpendicularity · parallelismrequired
locationposition (optional) · concentricity · symmetry (required)
runoutcircular-runout · total-runoutrequired

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-datum maximum / 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:

  1. Geometry per child, bottom-up: fold draw: to a path (corner modifiers applied cyclically through close()), collect its :segments, apply mirror: / revolve: (+ the edge lines and thread: dressing), expand pattern:, build break:’s view map; nested drawings lower first, becoming rigid subtrees. Compute each node’s geometry bbox (stroke excluded) and paint bbox (core).
  2. Place children: origins on the datum, translate: applied.
  3. Mates: walk from the ground; rotate first, seat, the child’s own translate after; flag cycles and over-constraints.
  4. Measure every annotation’s anchors against the seated, unbroken geometry; compose the texts (glyph + number / label + tol: + count).
  5. Annotate: assign dims to sides and pack the rows in source order; auto-place callout texts outward; ray-cast leader tips; land the elbow.
  6. 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>.
  7. 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:

Typesymbol:Body (mm)
|bed|queen (default) · king · double · single1500 × 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 · roundthe 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 · dishwasher600 × 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:

ChildIsDrawn
a 3+-pin part (|component|, |opamp|, |J|, |Q|), or anything explicitly placed (cell:)an anchoron the scope’s track grid
a |label|, or an unplaced 1–2-pin parta satelliteseated at the pin its wire touches
a link (a - b)a wirerouted orthogonally, square-cornered, junction-dotted
a one-ended link with text or a capsule (U7.DIAG - "NSTDBY", c24.p2 - |gnd|)a label wirea 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 chromean 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:

TypeMintsPinssymbol: variants
|R|R1…p1 p2 / p1 p2 wpot (the wiper is w) · ntc
|C|C1…p1 p2polarized
|L|L1…p1 p2
|D|D1…a kzener · tvs · schottky
|LED|LED1…a k
|Q|Q1…b c e / g d snpn (default) · pnp · nfet · pfet
|Y|Y1…p1 p2
|F|F1…p1 p2
|FB|FB1…p1 p2
|SW|SW1…p1 p2toggle (default) · push
|BT|BT1…plus minuscell (default) · battery
|V| / |I|V1… / I1…plus minusdc (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 setgnd · 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:

RunText
horizontalabove
verticalbeside, 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| -> a lands p1, p2 stays 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 - x enters 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’s shape: (- plain, -> right, -< left, -<> both, -* round) exactly as an operator’s line sets stroke-style (SPEC 9); an explicit shape: 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, plain stroke-style).
  • A two-ended wire’s net name is its link labelU7.VS - c24.p1 "VM" — placed by along: 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: points on 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 .class rule 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.)

Propertyflowgridtreesequencechartpiedrawingschematic
directionrow/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 / spancolumns + 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):

PropertyValueDefault
fillcolour · none · gradient · auto--fill (box) · none (block/line) · --icon-fill (icon) · currentColor (text) · none (root — the scene background, SPEC 18)
colorcolourinherits (--text-color) — text colour for the subtree
opacity0..11
strokecolour · none · gradient--stroke (--group-stroke on group)
stroke-widthnumber2 (|group| and a sequence frame: 1)
stroke-stylesolid·dashed·dotted·wavy·center·phantomsolidwavy link-only by design; center / phantom on shapes and |line|s (SPEC 15.7)
radiusnumber0 (block/rect) · 8 (box/group) — rect + polyline join; on a hex / diamond / slant / poly an error
shadowN · dx dy · dx dy blur · dx dy blur coloroff — tint --shadow-color

Text — all inherit (SPEC 6); text-valid on a bare string:

PropertyValueDefaultKind
font-familyident · string · --var--font-familylive
font-sizenumber15 — chrome derives from it: a link label 11∕15, a caption 12∕15 (SPEC 6)baked
font-weightnormal·medium·semibold·bold·400·500·600·700medium (500, --font-weight)live — measured at the resolved weight (SPEC 6); another number is an error, arbitrary 100–900 ⌛
font-stylenormal · italic · obliquenormallive
text-transformuppercase · lowercase · capitalize · nonenonebaked — into the text, measured as drawn (SPEC 6)
text-decorationunderline · overline · line-through · nonenonelive
text-shadowdx dy blur colourlive (numbers gain px)
letter-spacingnumber0baked
line-spacingnumber0baked

Box model & placement (SPEC 5):

PropertyValueDefaultNotes
width · heightnumber · autoautoborder-box; a floor. |image| needs both.
max-widthnumbercaps an auto width; a width above it is invalid; text inside wraps to it (SPEC 5).
text-wrapwrap · nowrapwrapwhether text breaks to honour max-width; inert without one (SPEC 5).
paddingN · v h · t r b l0 (block) · 20 (box)inner padding; places content.
pinnone · center · edge · cornernoneout-of-flow anchor; a box property (not text).
translatex ypost-placement nudge; any node incl. text.
rotatedegrees0turn about bbox centre; any node incl. text.
layerinteger0 (flow) · 1 (pinned)paint order; ties → source order.
scalenumber > 01the drafting ratio (2 = 2 : 1) — nearest-wins; position scales by the parent, shape by self (SPEC 15.1).
patterngrid(…) · radial(…)replicate about the node’s position (SPEC 15.4).
mirroraxis list · none · autoautoreflect 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):

PropertyValueNotes
hrefquoted URLwraps the node / link in <a href> — clickable.
hintquoted stringemits a <title> child (tooltip + screen-reader name).

Type-owned properties

Read on the listed primitive; required where noted (SPEC 7).

PropertyOnValueNotes
points|line| |poly|x y, … · parametric u exprvertex list; required.
samples|line| |poly|, chart fn:integersample count (geometry default 2 — a straight segment; chart default 24).
path|path|quoted SVG pathrequired; native top-left coords.
src|image|quoted URL / data URI / local pathrequired; a local file embeds (SPEC 7).
symbol|icon| · |surface-finish| · |label| · the discretes · |door| and the floorplan fixtures (not |stairs|)identPhosphor 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 · stretchmaps content into the box (size unchanged); auto default, |sign| contain.
skew|slant|degrees (-89,89)15.
multipleclosed primitivesN · dx dyone offset duplicate behind.
marker · marker-start · marker-end|line|, linkssee SPEC 7endpoint / vertex glyphs; from the operator on a link.
draw|sketch|pen calls + :segmentsrequired (SPEC 15.3).
revolve|sketch|x-axis / y-axissolid of revolution — fused fold + |shoulder| lines (SPEC 15.3).
thread|sketch| |hole| round geometryseg pitch, … · pitchISO 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] groupscut 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.

PropertyOwnerValueDefaultRef
layoutany containerflow·grid·stack·tree·sequence·chart·pie·drawing·floorplan·schematicflowSPEC 11
directionflow, chart, treerow·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 · paddingflow, gridsee matrix (gap 36 in a flow, 12 in card content)SPEC 11, SPEC 12
columns · rowsgrid · 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 · spangrid 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 1SPEC 12
data · fnchart serieslist / pairs / (…) exprSPEC 14.3
labelschart seriesquoted-string listSPEC 14.3
curve|line| |area|linear·smooth·steplinearSPEC 14.2
baseline|area|numberaxis zeroSPEC 14.2
axisseries, |mark|, |band|an |axis| idSPEC 14.4
bars · categories · samples|chart|see SPEC 14.1grouped · indices · 24SPEC 14
hole|pie|0 ≤ n < 10SPEC 14.7
legend ⌛ · tooltip|chart| |pie|, series (tooltip)see SPEC 14auto · autoSPEC 14
value|slice| |bubble|number ≥ 0SPEC 14
at|mark| |bubble| · |plane| · an opening (|door| / |window| — its station on on:’s segment)V / X Y · N [x-axis | y-axis] · NSPEC 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.4SPEC 14.4
formatchart / drawing scope · |axis| · series · a dimension — inheritsauto (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)autopresentation only, never measurement; composes before unit:, tol:, the /R/° glyphs, and counts (SPEC 14.4, SPEC 15.6)
side (homonym: also an |axis|’s, above, and a dimension’s, below)first-level |topic|, bilateralleft · rightthe split ruleSPEC 12
placesequence |note|over · left · right, then id(s)SPEC 13
activation|sequence|auto · noneautoSPEC 13
scale (homonym: an |axis|’s is linear·log·time)any nodenumber > 01SPEC 15.1
unit (homonym: an |axis|’s is its quoted tick suffix)datum scopes · |axis|px·mm·cm·m·in — an authored one inheritsmm (a drawing) · px (a stack)SPEC 15.1, SPEC 14.4
densitythe rootnumber > 04px per mm, screen/raster only (SPEC 15.1)
tola dimension · a control rowt / +u -l / fit ident · number > 0 (a frame’s zone width)SPEC 15.6, SPEC 15.9
characteristica control row (|control|, or a one-row |feature-control|)the ISO 1101 ident set— (the smart label)SPEC 15.9
zonea control rowdiameter · sphericalSPEC 15.9
materiala control rowmaximum · leastSPEC 15.9
datumsa control rowletters, each + optional maximum / least — ≤ 3SPEC 15.9
modifiersa control rowprojected N · free-state · tangent-plane listSPEC 15.9
sidea dimension / callout (also |axis|, above)side · corner · left / right along an aligned spanby axisSPEC 15.6
projecta (-) dimensionhorizontal · vertical · alignedinferredSPEC 15.6
gapa matesigned number — separation along the normal (a dimension stands off by clearanceSPEC 21)SPEC 15.5
facing|plane|left·right·up·downby planeSPEC 15.8
of|drawing|a |plane| / |magnifier| idSPEC 15.8
ISO 7200 fields|title-block|quoted stringSPEC 15.8
thicknessa floorplan scope · |wall|inherits, nearest winsnumber > 0200 mmSPEC 15.11
on|door| |window|a straight wall :segment, barerequiredSPEC 15.11
hinge|door|start · endstartSPEC 15.11
swing|door|left · rightleftSPEC 15.11
steps|stairs|integer ≥ 2requiredSPEC 15.11
number|pin|integerSPEC 16.2
prefix|component| lineage, the discretesquoted stringthe type name (|component|: "U")SPEC 16.2
shape|label|plain·left·right·both·roundplain — a label wire’s marker sets itSPEC 16.4, SPEC 16.5
pins|J|integer ≥ 1SPEC 16.2
side (homonym)|pin|left·right·top·bottomthe bilateral split (left on a |J|’s generated pins)SPEC 16.2
side (homonym)a |label| · a schematic wireleft·right·top·bottomabove a horizontal run, the freer side of a vertical onewhich side of its trace a net name sits (SPEC 16.4)

A link is styled like a node (SPEC 9) — its wire takes stroke*, its labels the text props. Its own properties:

PropertyValueDefaultNotes
clearancenumber16 — 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.
routingorthogonal · natural · straightorthogonalwiring strategy; scene config, cascades (ROUTING.md).
alongfraction listautolabel positions along the route.
marker · marker-start · marker-endmarkerfrom the operatorendpoint glyphs (SPEC 7).
corner-radiusnumber · autoauto — the clearance-derived capa 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:

FamilyClasses
corelini-node · lini-{type} · lini-style-{class} · lini-text · lini-canvas · lini-gutter
linklini-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)
chartlini-chart-title · lini-chart-text (every other chart string) · lini-chart-legend · lini-chart-label · lini-chart-tip · lini-tip-N / lini-hit-N
sequencelini-sequence-tab · lini-sequence-guard · lini-sequence-message
treelini-level-N · lini-hue-{name} (the mindmap walk)
drawinglini-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
floorplanlini-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)
schematiclini-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
highlightlini-tok-{kind} — a source listing’s token spans, not a figure’s: lini highlight writes them and lini highlight --css paints them (SPEC 20)
markerlini-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 to block for 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’s unit: × the root density: fold into its one internal px-per-unit — its pixels per drawing unit at ratio 1, which the cascading scale: multiplies at layout — and a floorplan scope’s unit: 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 -> ca -> 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-N classes, and a mindmap’s palette-walk rules (SPEC 12); link auto-create (an undeclared endpoint x|box#x| "x"); and capsule hoisting (an endpoint capsule → a declaration at the statement’s position + a reference, anonymous ones under minted lini-cap-N ids — 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):

  1. 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).
  2. 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 its columns: has — the header band, the carried column alignment, an entity’s full-width spans (SPEC 8).
  3. 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-style unless 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]
FlagMeaning
-o FILEOutput path (default stdout).
--format svg|htmlsvg (default) or HTML wrapper.
--checkThe full compile, no artefact written — the same errors and warnings, the same exit code. (fmt --check is that subcommand’s own flag — below.)
--port Nlini serve only — the preview port (default 7700).
--jsonEmit 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/BA built-in theme (dark, high-contrast, …), a CSS file of --lini-* overrides, or a light/dark pair (light/dark).
--no-warn / --strictSilence warnings / treat them as errors — under every form (--check, --json, SVG) alike.
--staticA self-contained SVG that renders identically anywhere (SPEC 10.6, SPEC 18).
--embed-fontEmbed 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.
--watchRecompile on every input change (requires -o).
-h, -VHelp / 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

ConditionMessage
Unclosed stringunterminated string literal
Bad numberinvalid number literal
Bad escapeinvalid escape sequence '\X'
Stray characterunexpected character 'X'
Operator outside a groupmath operators appear inside ( ) — e.g. padding: (8 * 2)

Properties & validation (SPEC 17’s strict/lenient rule)

ConditionMessage
Unknown property nameunknown 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 wornclass '.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

ConditionMessage
Duplicate idduplicate id 'X' (previously at L:C)
Unknown type / classunknown type 'X' / unknown class '.X'
Inheritance cycle / depthcycle 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 prefixan id may not begin 'lini-' — the prefix is reserved for generated names
Class inside the barsa class follows the bars — write '|box| .hot', not '|box.hot|'
Symbol set twicean icon's symbol is its label or 'symbol:', not both
Text carries childrentext 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 blocka declaration belongs in a '{ }' block
Bare node on the canvasa node leads with bars — write '|box#X|' (a bare name is a link endpoint)
Bare type in the stylesheeta type only appears in bars — write '|box| { }' to style every box
Missing declaration ‘;’a declaration ends with ';'
Style block holds non-decla '{ }' style block holds only declarations
[ ] holds a declarationdeclarations go in '{ }', not '[ ]'
Two head labelsone inline label — put two or more in a '[ ]'
Label after a classa label comes before classes — write '|box| "X" .hot'
Stylesheet after canvasthe stylesheet '{ }' must come first, before any instance
Glued compound in a rulea selector unit can't glue a type and a class — space them (descendant) or style '.hot'
Spaced class chainclasses glue into a chain — write '.hot.loud', no space

Links & routing

ConditionMessage
Unknown endpoint (path)link endpoint 'X' not found at <scope> + ; did you mean 'A', 'B'?
Auto-create shadows a nodeendpoint '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 operatorslink chain mixes operators 'X' and 'Y'
Chain < 2 nodeslink 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 instancea 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 strategyrouting takes orthogonal, natural, or straight
Unknown side':X' is not a side — use top, bottom, left, or right
Link labels splitkeep a link's labels together — write 'a -> b [ "x" "y" ]' (warning)
Capsule endpoint in a drawinga 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

ConditionMessage
Invalid / out-of-range colorinvalid 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 stopsgradient() needs at least two colour stops
linear-gradient without an anglelinear-gradient needs an angle first, then ≥ 2 colour stops — e.g. linear-gradient(135, --teal, --sky)
Single-quoted stringsingle 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 gapa container's 'gap' must be ≥ 0 — a mate’s gap: may go negative (SPEC 15.5)
skew out of rangeskew: N must be in (-89, 89)
Unknown name in an expressionunknown name 'foo' in an expression
Function arity'sin' takes 1 argument(s), got 2
Spaced call parena call's '(' glues to its name — write 'rgb(…)'
hatch() off fill'hatch' is a fill — 'stroke' takes a colour or gradient
Unreadable image pathcannot 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

ConditionMessage
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 rangecell: 5 _ exceeds columns=3

Layout — wrap (SPEC 5)

ConditionMessage
nowrap text can’t fittext cannot fit 'max-width: 80' without wrapping — widen it or drop 'text-wrap: nowrap'
Non-text child wider than the capa child is wider than 'max-width: 80' — only text wraps
width above max-width'width: 200' exceeds 'max-width: 120'

Layout — tree (SPEC 12)

ConditionMessage
|topic| outside a tree'|topic|' builds a tree — it belongs in a 'layout: tree'
No root topica tree needs exactly one root '|topic|'
A second root topica tree has one root — '|topic|' 'X' is a second
side: top/bottom in bilaterala 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

ConditionMessage
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 placementa 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

ConditionMessage
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 seriesa pie's children are '|slice|' only
A |slice| with childrena '|slice|' is one wedge — multi-ring pie / sunburst is deferred (SPEC 24)
Empty chart / piea 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 countseries 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 textset '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: idaxis '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 domaina '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 domainan 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 axisa 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: radiala 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 zeroa '|slice|' value must be ≥ 0 / a pie's slice values sum to zero

Layout — drawing (SPEC 15)

ConditionMessage
|sketch| without draw:'|sketch|' requires 'draw'
|hole| / |pitch-circle| / |magnifier| without width:'|hole|' requires 'width' — its diameter
Unknown pen call / arityunknown draw call 'X' / 'arc' takes (dx, dy, r) or (r, deg)
fillet / chamfer off a corner'fillet' modifies the corner between two segments
Floating :segmenta ':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 smallarc 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 cubica '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 endpointdimension 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 leadera 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 anchoran 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 profilea station '⌀' reads a revolved profile — 'revolve: x-axis'
revolve: + mirror: togethera 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: segmentno 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 viewa detail magnifies a base view — 'of' can't name a marker inside another sourced view
at: off the modela 'plane' at N sits off the model
Bad facing:'facing' turns the arrows — left, right, up, or down
Marked projection opa projection line is unmarked — write 'side.screw:head - end.od:top'
Projection ends in one viewa projection link ties two views — both ends read 'side'
Projection end off a viewa projection link ties drawing anchors — 'notes' is not in a drawing view
Cross-view dimension / matea dimension reads one view — a cross-view correspondence is a construction link ('a - b')
Authored cell on a generated fieldcell 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 :segmentno segment ':step' on 'body' + suggestions
Duplicate :segment in one draw:':step' is already named in this 'draw:'
Label on a matea mate takes no label
gap: on a point matea point mate coincides — 'gap' needs directed anchors (sides or named edges)
Non-parallel mate directionsmated anchors must face along one axis — 'a:left || b:top' has no shared normal
Over-constrained matemate 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 indexno copy 'bolt.5' — the replication places 4
Duplicate datum letterdatum '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 characteristicunknown characteristic 'flatnes'; did you mean 'flatness'?
Characteristic set twicea 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 formsa 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 referenceno 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 seata seat needs a face — anchor a side or a named edge ('sf || plate:top')
Seat with no geometry enda 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 linka 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 dimensiona dimension stands off by 'clearance' — 'gap' is a mate's separation
side: off-axisa horizontal dimension stacks on top or bottom / a vertical dimension stacks on left or right
Parallel (<) edgesthe 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 extentthe 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 labela text callout ends its statement — chain before it
Mate in a flow scopea '|row|' places its own children — mates seat a drawing's
Empty drawinga drawing needs at least one geometry child

Layout — floorplan (SPEC 15.11)

ConditionMessage
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 openingan 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 doora 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 thicknessarc 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)

ConditionMessage
Schematic type outside the scope'|R|' belongs in a 'layout: schematic' (every schematic type)
:side on a terminala terminal owns its connection — a pin or label takes no ':side'
Non-90° rotation on a connection-bearing parta schematic part rotates in 90° steps — 0, 90, 180, or 270
A mirror: on such a part naming no axis of its owna 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 takenboth pins of 'R5' are wired — name one ('R5.p1')
Minted ref as an endpointlink endpoint 'R1' not found — a minted ref is display-only; give the part an id to wire it
Marker on a part-to-part wirea 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 labela 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

ConditionMessage
Closed graphno legal route: every side entry or channel is closed at this layout
Blocked fixed portfixed port blocked: a body covers the port's landing
Crowded fixed portsfixed ports closer than the minimum pitch on one side
Conflicted fanfan ends carry two different fixed ports
Pinned self-loopself-loop with both ends forced onto one side

Output (SPEC 18)

ConditionMessage
--static cannot outline a runno 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 labelsstyleable 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:

  1. the four draw_op tokens — 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;
  2. 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;
  3. the widened endpoint point set in drawing scope;
  4. the numeric copy index in an endpoint path — plate.bolt.2; the lexer glues . + digits in endpoint position only, so 1.5 in value position stays a number;
  5. the (-) dimension-family sel_unit at a stylesheet statement head — a leading ( there is unambiguous, calls and groups appearing only in value position;
  6. 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;
  7. the pen_item form inside a draw: 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 names text, marker, canvas, scene, cut: not instantiable types — node is 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-NSPEC 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 13SPEC 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 / fr grid tracks — a track is a size, auto, or repeat(N[, size]); equal tracks are repeat(N) (SPEC 12).
  • gradient fills on text — gradients fill nodes today (SPEC 10.3).
  • radius on 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 dir knob 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; --static outlines 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-on icons cargo 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 radial charts — a compile error today (SPEC 21); column and row are built.
  • explicit per-axis tick text — categories: covers the x axis today (the series’ per-datum text is labels:, SPEC 14.3).
  • gauge (a partial arc for one value); stacked areas (bars: stacked extended 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 :segment is unaddressable (the name reads the drawn original; the unary mirrored readings cover the turned-profile cases; a pattern: 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:port onto a sketch’s authored :segments remains deferred.
  • repeated-segment counting — one :segment on several corners auto-prefixing 4× R3, as pattern: 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; a break: station through a curve() (lines and arcs clip exactly today — move the stations off the cubic) and break: 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 / h bound 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 an along: 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"
Cat Bowl of oats Water Kitchen Inlet Outlet Closet Inlet Outlet Fridge watches restocks flows flows

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"
  ]
]
Launch Product MVP Docs Sales Leads CEO CTO Backend Frontend COO Ops Org

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 }
Browser API Sessions alt [password ok] [wrong] rate-limited click login POST /login lookup record 200 + cookie dashboard 401

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 }
]
15 cm³ · 1.8 kW: 9 15 cm³ · 2.3 kW: 7 30 cm³ · 1.8 kW: 15 30 cm³ · 2.3 kW: 13 50 cm³ · 1.8 kW: 24 50 cm³ · 2.3 kW: 20 0 5 10 15 20 25 15 cm³ 30 cm³ 50 cm³ Cycle time (s) 1.8 kW 2.3 kW 15 cm³ · 1.8 kW: 9 15 cm³ · 2.3 kW: 7 30 cm³ · 1.8 kW: 15 30 cm³ · 2.3 kW: 13 50 cm³ · 1.8 kW: 24 50 cm³ · 2.3 kW: 20 1000 bar @ 93 0 500 1000 Pressure (bar) 0 50 100 Speed (mm/s) Injection profile Ads: 40 (40%) SEO: 30 (30%) Direct: 30 (30%) Spend Ads SEO Direct Ads: 40 (40%) SEO: 30 (30%) Direct: 30 (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 }
]
Speed Range Armor Cost Stealth 1 2 3 4 5 Profiles Scout Cruiser GLM-5.2: 35, 63 GLM-5.2: 42, 72 GLM-5.2: 84, 75 0 20 40 60 80 score % 40 50 60 70 80 tokens (k) Effort vs. score Base High Max GLM-5.2: 35, 63 GLM-5.2: 42, 72 GLM-5.2: 84, 75

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";
  }
]
⌀13 8 40 28 M8×1.25 DIN 912 — M8 × 40 7 Title Socket cap screw Dwg No. DIN 912 — M8 × 40 Rev A Sheet 1/1 Date 2026-07-08 Drawn AM 1 2 3 4 1 2 3 4 A B A B

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
F STUDIO 27 m² 4.75 2.15 7 4.6