Architecture

How the library is put together — the pipeline, the layer stack, and the two ideas the whole design turns on.

50modules
230internal imports
0layering violations

Sauvignon is one XML → XML transform: a high-level relational description lowered to low-level, absolutely-positioned geometry. Everything below is read from the source at build time, and the layer, layout and chart diagrams are drawn by Sauvignon from the package's own import graph.

The pipeline

Three stages, two intermediate representations. Each stage only knows about the representation on either side of it.

<diagram padding="14">
  <row gap="14" align="center">
    <parallelogram id="src">.svgn</parallelogram>
    <rect id="parse">parse</rect>
    <hexagon id="model">model</hexagon>
    <rect id="layout">layout</rect>
    <hexagon id="scene">scene</hexagon>
    <rect id="render">render</rect>
    <parallelogram id="svg">.svg</parallelogram>
  </row>
  <connect from="src" to="parse"/>
  <connect from="parse" to="model"/>
  <connect from="model" to="layout"/>
  <connect from="layout" to="scene"/>
  <connect from="scene" to="render"/>
  <connect from="render" to="svg"/>
</diagram>

The hexagons are the two IRs, and they are the reason the ends stay independent:

The layer stack

The package is a strict one-way dependency stack. A lower layer never imports a higher one, which is what lets you read it bottom-up.

Each layer earns its place:

That invariant is not a comment; it is checked on every build by walking the package's relative imports (the house rule is that internal imports are always relative; see the counter at the top of this page). If someone makes foundation import engine, the build says so.

The relational core

This is the idea the whole engine turns on. Positions are not assigned by walking the tree — they are solved.

The layout tree desugars into soft difference constraints: each child's position expressed as an offset from its container. <align> and <distribute> compile into hard constraints referencing elements by id. Both go into one pool, and hard wins.

<diagram padding="14">
  <row gap="50" align="start">
    <column gap="10">
      <rect id="p">soft: in a column</rect>
      <rect id="q">soft: below it</rect>
    </column>
    <column gap="10">
      <rect id="r">another column</rect>
      <rect id="s">and its child</rect>
    </column>
  </row>
  <align of="q r" edge="center-y"/>
</diagram>

Because both kinds of constraint live in one pool, an explicit relation overrides structural placement rather than fighting it. That is what allows a node to be stacked, aligned and connected at once, and what allows aligning two elements in different parents — something a strict layout tree cannot express at all.

The solver itself is deliberately small: a weighted union-find over 1-D difference constraints, pos[a] − pos[b] = offset, with x and y solved independently. Variables in one component have fixed relative positions.

Inside the layout engine

Layout runs in five phases: measure (bottom-up intrinsic sizes) → build (desugar the tree into constraints) → solve → frame (shift to the origin, size the canvas) → emit (produce scene primitives).

The package mirrors that, with one deliberate compromise:

Only direct dependencies are drawn. resolve does import base, but that edge is already implied by resolve → measure → tracks → base, so it is left out — the standard way to draw a dependency graph, and what stops arrows from running through the boxes in between.

base holds the leaf helpers, tracks the grid track/area/line math, connect the edge routing, and resolve the driver. The compromise is measure: the measure pass and grid track sizing are mutually recursive — measuring a grid requires measuring its children, which may themselves be grids — so splitting them into separate modules would create an import cycle. They stay in one module so the package graph stays a DAG.

Charts are computed, not solved

A chart's geometry is not expressible as difference constraints — axes, lines and areas need real coordinates. So charts take an escape hatch: each one becomes a Figure, an opaque box that composes its own primitives in local coordinates and is placed by the solver as a single unit.

The chart engine reads data, not XML. The front end freezes a chart's subtree into a ChartElement — a frozen record with the four members the engine actually used — so nothing under engine/ imports an XML parser and no XML node survives into a Figure's deferred draw. That split also fixes a staging bug: reading every field eagerly, before the Figure is built, means a mistyped numeric column is a parse() error rather than something that surfaces from layout().

Opaque, but — for <plot> and <facet> — not rigid, and not pre-composed. Such a Figure holds a render(w, h) callback rather than a finished list of primitives, and that callback is invoked after layout has resolved the box's size — so a chart takes part in grow and stretch like anything else and reflows into whatever cell it lands in. Give a plot grow="true" in a wide row and it is the bars that get wider, not a picture that gets scaled. Only an author-pinned width or height makes a Figure genuinely rigid.

This is why a bar chart and an architecture diagram can share one engine, and why <facet> works: facets are just Figures laid out by the ordinary relational engine, so small multiples cost no new machinery.

Modules are grouped by how far below the top of the graph they sit — each band is one step further from the tags nothing imports, so the bottom row imports nothing else in the package. spec — the frozen chart element the front end hands over — is the one true leaf, which is what you would hope for a type everything reads and nothing reasons about. data and color sit just above it as the shared helpers; cartesian and polar are the two coordinate frames; every remaining module is a single chart tag. One tag leans on another: timeline draws its periods as a chronology draws its stretches, with the chronology's callouts, so it sits a band above chronology. The Grammar of Graphics shows through here — polar is not a chart type, it is a projection, which is why pie = bars + polar.

(This one is drawn as bands rather than as a dependency graph like layout/ above. With fourteen modules and thirty-seven edges the arrows cross into a hairball — a small, honest demonstration of the ceiling described at the end of this page.)

The count went down by one without the edge count moving, which is how the attribute table turned out to be in the wrong place. It sat in chart/ and nothing in chart/ imported it — only the front end did, to refuse what an element does not read. It describes the language's elements rather than the chart engine's, so it lives beside the tag registry now: vocabulary/registry says which tags exist, vocabulary/attrs says what each of them takes.

Extensibility

Two things are data rather than code:

It is not true that the parser has no hardcoded tag list. The six chart tags and the five plot layers are dispatched by an if tag == … chain in parse.py, and only marks are genuinely data-driven. Making chart tags registrable the way marks are is the honest next step here.

Appearance is the third: it is CSS. The renderer emits semantic classes and a palette of custom properties, so restyling is overriding variables, and dark mode falls out of prefers-color-scheme for free.

The one real ceiling

Worth stating plainly, since it bounds what the design can grow into. The difference-constraint solver handles equalities on independent axes. It cannot express:

That ceiling is why there is no automatic tree or graph layout. It is also contained: it lives in foundation/solve and the constraint-building in engine/layout/resolve, so a stronger solver could be swapped in without touching the front-end, the IRs, or the backend. Chart coordinate systems are not blocked by it, because they are a render-time projection rather than a constraint.