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 Ledger — every baked default has one home (the implementation’s ledger module), so the whole look is tuned from one place. The root’s padding (20) frames the whole scene — the SVG margin.

The constants below are engine anatomy — chrome with no ledger row, and the link seats a drawing or schematic scope lays under the ledger’s own defaults (SPEC 17) — listed here once. So are the chart-plane sizes (SPEC 14.1): a chart defaults 360 × 220, a pie / radial chart a 280 square — a chart cannot size to its content, so these stand in for auto.

The chart type scale and its stand-off (SPEC 14.6):

chart title 15    chart text 12 (every other string a chart draws)
chart gap 15 (the plot's gutter to its title / legend)
chart clearance 6 (the scope's config — text off what it labels)

The drawing chrome (SPEC 15) — sheet-space, never scaled:

dim clearance 18 (the drawing scope's link default)
dim-ext-gap 3    dim-ext-overshoot 3     halo-margin 2
dim-arrow 12 × 4    datum-triangle 11
note-offset 20   note-landing 12   note-text-gap 5
hatch-pitch 6    hatch line-width 0.75   break-gap 12     tol-stack 0.7
center-mark-overhang 4    drawing link stroke-width 1   drawing link font-size 12
plane-overhang 6 (then the 10 thick end, standing in it)   plane-arrow-shaft 13
plane-letter-gap 7   plane-letter 12   title-field width 96 (a title-block field's floor)

The schematic chrome (SPEC 16) — sheet-space:

schematic gap 100 (the coarse lattice — the part pitch, five fine ones; SPEC 16.1)
pin-pitch 20 (the fine lattice: pins, stub tips and wire tracks — and the
  schematic world's track quantum, ROUTING.md)
pin-stub 20    junction 4 (radius)    tag-point 8 (a flag's nose reservation; the nose draws at 45°)
net-label-run 40 (2 × pin-pitch — the floor on a plain net label's run of
  trace; a longer name grows it, `width:` raises the floor — SPEC 16.4)
net-label-offset 4 (the clear space that name keeps off the trace, and off
  the run's two ends)
pin-number offset 9 (across the lead)    readout offset 24 (beside a turned part's axis)
readout gap 8 (part edge → its ref / value)    readout stack 4 (between the two)
schematic clearance 10 (the scope's config; past pin-pitch it errors, SPEC 16.1)
schematic link stroke-width 1.5    corner-radius 0 (the scope's link default)

10.6 --static

Class rules and inline style= work everywhere, but CSS variables don’t — resvg and librsvg fail var() in every position (browsers, even <img>-embedded, are fine) — and neither honours @font-face. --static keeps the rules but inlines every var(--lini-name) as its literal and outlines text to paths (SPEC 18): no runtime theming, but a self-contained SVG that renders identically anywhere, installed fonts or none.

10.7 Expressions & functions

A parenthesized expression (…) holds compile-time math — folded to a literal (a number, or a point (x, y) for geometry) when the diagram compiles. Parentheses are the only place operators appear: outside them - is a link or a number’s sign, < / > are markers, // a comment, so the parens are what let * mean “times”. A value stays paren-free until an operator does. A call’s own parens count, so an operator inside a call’s arguments needs no inner group — what makes math usable inline everywhere; a signed number is a sign, not an operator, so -2 stays bare (translate: -35 20) — to subtract, group it:

gap: 8;                     // a literal — bare
width: scale(3);            // a call — bare, no group
padding: (8 * 2);           // an operator → a group (= 16)
draw: move(-2, 5) up(8)     // calls and signed numbers — bare
draw: right(w / 2)          // an operator in a call's own parens — no group

Inside a group the language is small and total:

  • Operators + - * / ^ (^ power, right-associative), unary -, grouping ( ), comparisons < <= > >= == !=, the 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.
  • Locals — name = 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 u — u sweeps 0 → 1, sampled at samples: points into a vertex list, drawing curves, waves, and spirals procedurally:

|line| { points: (u*300, 20*sin(2*pi*3*u)); samples: 60 }   // a sine wave
|line| { points: wave(20, 3); samples: 60 }                 // the same, named

Everything an expression touches bakes — a computed size, a sampled curve — so a standalone SVG never depends on host CSS. The same sample-an-ambient seam feeds a chart’s fn: (with x bound to the domain — SPEC 14). Unknown names, wrong arity, and out-of-range results are compile-time errors (SPEC 21).


A container picks an engine with layout:. Every engine reads the same core (Part I) — the cascade, paint, text, the box model, links, colour — and adds only its own placement algorithm, its own child roles, and a few scoped properties. This part is the family; each section states just its delta.