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.