10. Colour, Variables & Expressions
CSS variables theme the visual layer — colours and the font family. Everything that affects layout — sizes, gaps, padding, and font size — is a baked constant, so a standalone SVG never depends on host CSS. This section also holds the expression engine (10.7), the one place operators appear.
10.1 Visual variables (live, themeable)
Each colour is a light-dark(LIGHT, DARK) value, so one SVG carries both modes:
--lini-bg light-dark(white, #1b1b1f) the scene background
--lini-fg light-dark(black, #e8e8ea)
--lini-fill light-dark(white, #26262b)
--lini-stroke light-dark(#444, #9aa0a6)
--lini-stroke-dark light-dark(black, white) the primary drafting tone — pen geometry, dimension/leader linework, and their heads read full black on white (the ISO print look)
--lini-stroke-light light-dark(#0000008b, #ffffffa3) the secondary line tone — drafting's thin support lines (centerlines, break lines, extension lines): full black/white at reduced alpha, so a support line crossing dark geometry blends toward it instead of greying it
--lini-accent light-dark(#0a84ff, #4aa3ff)
--lini-accent-text white text on an accent fill (e.g. a badge)
--lini-muted light-dark(#888, #9aa0a6)
--lini-danger light-dark(crimson, #ff6b6b)
--lini-warn light-dark(orange, #ffb454)
--lini-stray light-dark(crimson, #ff6b6b) the stray-link fallback (ROUTING.md, Impossible layouts)
--lini-group-stroke light-dark(rgba(0,0,0,.4), rgba(255,255,255,.4))
--lini-group-fill light-dark(rgba(0,0,0,.03), rgba(255,255,255,.05))
--lini-header-fill light-dark(rgba(0,0,0,.06), rgba(255,255,255,.08)) the table / entity header band
--lini-icon-fill light-dark(rgba(0,0,0,.16), rgba(255,255,255,.18)) the soft body behind a duotone icon
--lini-caption-color light-dark(rgba(0,0,0,.5), rgba(255,255,255,.55))
--lini-footer-color light-dark(rgba(0,0,0,.5), rgba(255,255,255,.55))
--lini-grid light-dark(rgba(0,0,0,.1), rgba(255,255,255,.14)) the chart gridline tint
--lini-tip-bg light-dark(#333, #e8e8ea) the chart tooltip card's surface ([SPEC 14.8](#148-tooltips))
--lini-tip-fg light-dark(white, #1a1a1f) …and its text
--lini-font-family "Google Sans", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif
--lini-font-weight 500
--lini-caption-font-weight 400
--lini-link-font-weight 400
--lini-text-color var(--lini-fg)
--lini-shadow-color light-dark(rgba(0,0,0,.2), rgba(0,0,0,.5))
--lini-wire light-dark(#0a7a2f, #4cc472) the schematic wire
--lini-component-fill light-dark(#fdf6d8, #3a3626) a part's body
--lini-component-stroke light-dark(#8a1c1c, #d98f8f) a part's outline
--lini-label-ink light-dark(#0e6a6a, #57c4c4) the net tag
--lini-pin-number light-dark(#00000073, #ffffff80)
--lini-sheet light-dark(#faf6ea, #22201b) the schematic scene wash
--lini-bg is the paper tone — what a root fill: --bg, a |page| sheet, and
a punched |hole| paint with. It is not painted unasked: a figure carries a
background only when the scene sets one (SPEC 18). The default stack leads with the bundled
proportional Google Sans (SPEC 6); its advances are
what the proportional metrics table measures, so a diagram measures identically
in every output mode (SPEC 18).
Dark/light is automatic. The compiler emits color-scheme: light dark on .lini,
so light-dark() follows the viewer’s OS (prefers-color-scheme) — no script, no
@media. A data-theme="dark" / "light" on the SVG or any ancestor forces a mode
(it flips color-scheme, and its higher specificity beats the OS). All defaults sit in
@layer lini.defaults, so unlayered host CSS still wins with no !important.
--static freezes the light arm into literals for renderers without light-dark()
(10.6).
10.2 The colour palette
Beyond the role variables, Lini ships a named-hue palette — pretty by default,
themeable, and dark/light-aware like everything else. Eleven hues, each a
light-dark() pair:
red rose orange amber lime green teal sky blue purple gray
Every hue carries five tiers, named for the job they do — not their lightness, which would invert in dark mode:
| Tier | Example | Job |
|---|---|---|
| wash | --teal-wash | palest — card and section backgrounds (a faint tint; a deep, muted surface in dark mode) |
| soft | --teal-soft | a gentle, lighter pastel fill |
| base | --teal | the everyday pastel — the bare name is the easy path |
| deep | --teal-deep | the strong tone — borders and strokes |
| ink | --teal-ink | deepest and most saturated — text and emphasis (the high-contrast tone in dark mode) |
The job-names hold across the dark flip — --teal-wash is always the faint
surface, --teal-ink always the high-contrast detail:
{ |card::box| { fill: --teal-wash; stroke: --teal-ink } } // a pretty card, one line
|box#n| { fill: --amber-soft }
The tiers are generated from one OKLCH seed per hue, so the ramp is perceptually
even and the eleven read as a family; the same space is open directly —
fill: oklch(0.7, 0.14, 200) (SPEC 2). Aliases cover muscle
memory: --yellow → --amber, --pink → --rose, --indigo → --purple,
--cyan → --teal. red stays clear for danger; rose is the decorating pink,
green an emerald, lime the lemony one.
The palette is tree-shaken — only referenced variables are emitted (SPEC 18).
10.3 Gradients
fill, stroke (a shape’s outline or a link’s wire), and gap-fill accept a gradient in place of a flat colour. Stops are
ordinary colours — palette --names flip dark/light and bake, a raw #hex is a fixed
literal.
| Form | Result |
|---|---|
gradient(--rose, --sky) | two stops, auto-angled 135° — any two hues blend cleanly |
gradient(--rose, --amber, --sky) | three or more evenly-spaced stops |
linear-gradient(135, --rose, --sky) | an explicit angle in degrees — the control gate |
radial-gradient(--rose, --sky) | a radial blend from the centre out |
|box#hero| { fill: gradient(--blue, --purple) } // a single-family sheen
|badge#tag| { fill: gradient(--rose, --amber, --sky) } // a three-colour pop
Each distinct gradient is emitted once as a <linearGradient> / <radialGradient> in
<defs> and referenced by url(#…) — deduplicated and shared like the drop-shadow
<filter>s (SPEC 18). objectBoundingBox units fit one definition to
any node at any size. The stops being palette vars, a gradient themes, flips, and bakes
like any other paint; gradient-on-text is deferred (SPEC 24).
Hatches. hatch() is a paint function beside gradient(), valid on fill
only — the drafting section-line texture, usable in any layout:
| Form | Result |
|---|---|
hatch(45) | section lines at 45°, pitch 6 |
hatch(45, 6) | explicit pitch (sheet-space px — hatch never scales, SPEC 15.1) |
hatch(45, 6, --gray-deep) | explicit line colour (default --stroke) |
hatch(45 -45, 6) | a space-group of angles — cross-hatch |
Angles use the drawing bearing (0 = up, clockwise — SPEC 15.3).
Each distinct hatch emits one <pattern> in <defs>, deduplicated like gradients; the
colour is an ordinary paint, so hatching themes, flips dark/light, and bakes. Hatch
line width is fixed (0.75) — a texture, not a stroke. hatch() on stroke is an
error — a stroke takes a colour or gradient.
10.4 --name references
--name is the visual-variable namespace, and only that. --name: value;
declares one (a built-in --lini-* name keeps its meaning; a new name is yours), and
--name in a value references it, emitting live var(--lini-name):
{
--brand: #ff6600;
}
|box#cat| { fill: --brand }
Alias a host var from CSS: .lini { --lini-accent: var(--my-brand-blue); }.
Layout values — sizes, gaps, padding, font-size, clearance — are not --name
variables: they bake (a runtime var() can’t be measured at compile time). Set them
with a literal, a rule (gap: 30;, |box| { radius: 4 }), or a (…) expression /
binding (10.7).
10.5 Layout constants (baked)
Baked compile-time defaults — override per-node, on the root, in rules, or in an
instance / link block. The per-property values and the template bundles live in
the Property Ledger — every baked default has
one home (the implementation’s ledger module), so the whole look is tuned from
one place. The root’s padding (20) frames the whole scene — the SVG margin.
The constants below are engine anatomy — chrome with no ledger row, and the link
seats a drawing or schematic scope lays under the ledger’s own defaults
(SPEC 17) — listed here once. So are the
chart-plane sizes (SPEC 14.1): a chart defaults
360 × 220, a pie / radial chart a 280 square — a chart cannot size to its
content, so these stand in for auto.
The chart type scale and its stand-off (SPEC 14.6):
chart title 15 chart text 12 (every other string a chart draws)
chart gap 15 (the plot's gutter to its title / legend)
chart clearance 6 (the scope's config — text off what it labels)
The drawing chrome (SPEC 15) — sheet-space, never scaled:
dim clearance 18 (the drawing scope's link default)
dim-ext-gap 3 dim-ext-overshoot 3 halo-margin 2
dim-arrow 12 × 4 datum-triangle 11
note-offset 20 note-landing 12 note-text-gap 5
hatch-pitch 6 hatch line-width 0.75 break-gap 12 tol-stack 0.7
center-mark-overhang 4 drawing link stroke-width 1 drawing link font-size 12
plane-overhang 6 (then the 10 thick end, standing in it) plane-arrow-shaft 13
plane-letter-gap 7 plane-letter 12 title-field width 96 (a title-block field's floor)
The schematic chrome (SPEC 16) — sheet-space:
schematic gap 100 (the coarse lattice — the part pitch, five fine ones; SPEC 16.1)
pin-pitch 20 (the fine lattice: pins, stub tips and wire tracks — and the
schematic world's track quantum, ROUTING.md)
pin-stub 20 junction 4 (radius) tag-point 8 (a flag's nose reservation; the nose draws at 45°)
net-label-run 40 (2 × pin-pitch — the floor on a plain net label's run of
trace; a longer name grows it, `width:` raises the floor — SPEC 16.4)
net-label-offset 4 (the clear space that name keeps off the trace, and off
the run's two ends)
pin-number offset 9 (across the lead) readout offset 24 (beside a turned part's axis)
readout gap 8 (part edge → its ref / value) readout stack 4 (between the two)
schematic clearance 10 (the scope's config; past pin-pitch it errors, SPEC 16.1)
schematic link stroke-width 1.5 corner-radius 0 (the scope's link default)
10.6 --static
Class rules and inline style= work everywhere, but CSS variables don’t — resvg
and librsvg fail var() in every position (browsers, even <img>-embedded, are
fine) — and neither honours @font-face. --static
keeps the rules but inlines every var(--lini-name) as its literal
and outlines text to paths (SPEC 18): no runtime theming, but
a self-contained SVG that renders identically anywhere, installed fonts or none.
10.7 Expressions & functions
A parenthesized expression (…) holds compile-time math — folded to a literal (a
number, or a point (x, y) for geometry) when the diagram compiles. Parentheses are the
only place operators appear: outside them - is a link or a number’s sign, < / >
are markers, // a comment, so the parens are what let * mean “times”. A value stays
paren-free until an operator does. A call’s own parens count, so an operator
inside a call’s arguments needs no inner group — what makes math usable inline
everywhere; a signed number is a sign, not an operator, so -2 stays bare
(translate: -35 20) — to subtract, group it:
gap: 8; // a literal — bare
width: scale(3); // a call — bare, no group
padding: (8 * 2); // an operator → a group (= 16)
draw: move(-2, 5) up(8) // calls and signed numbers — bare
draw: right(w / 2) // an operator in a call's own parens — no group
Inside a group the language is small and total:
- Operators
+ - * / ^(^power, right-associative), unary-, grouping( ), comparisons< <= > >= == !=, the ternarycond ? a : b. - Functions — the math library
exp ln log sqrt abs sin cos tan min max clamp floor round pow, and any you define (below); each returns a number or a point, calledname(args). (Colour / track builders likergb/repeatmake typed values, so they live in value position, never inside math.) - Constants
pi,e; scientific notation1e6,1.32e-6; the sample parametersu(geometry, below) and chartx; and your bound names, read bare (below). A bare name resolves: locals → the ambient (u/x) →pi/e→ your bindings. - Locals —
name = expr;binds for the rest of the group; the final expression is the value (no keyword, noreturn).=binds,==compares. A top-level,makes the value a point. Values are numbers and points — no strings, no loops.
(r = 40; n = 6; 2 * pi * r / n) // r, n are locals; the last line is the value
Bindings are written in the stylesheet with = — a name bound to a value, for reuse
in any expression. A scalar is name = value; a function adds a parameter list,
name(params) = value. The value is bare when it is a literal, a name, or a call,
and a group when it holds an operator, locals, or a point. = binds and reads
compile-time (baked), where : sets a live property — the two never meet:
{
my_radius = 5; // a scalar — read bare as `my_radius`
scale(n) = (100 * 1.2^n); // a function
wave(a, f) = (u*300, a*sin(2*pi*f*u)); // a function returning a point
}
|sketch#part| { draw: move(-my_radius, 0) right(2 * my_radius) up(my_radius); }
|box| { padding: my_radius; } // a scalar is a value wherever one goes
Call a binding anywhere a value goes — bare like rgb(…) / repeat(…), or inside a
group; a computed argument rides the call’s own parens
(|box| { padding: (scale(2) + 4); columns: repeat(3, 80 * 2) }).
Geometry. points: (on |line| / |poly|) may be a parametric expression in
u — u sweeps 0 → 1, sampled at samples: points into a vertex list, drawing
curves, waves, and spirals procedurally:
|line| { points: (u*300, 20*sin(2*pi*3*u)); samples: 60 } // a sine wave
|line| { points: wave(20, 3); samples: 60 } // the same, named
Everything an expression touches bakes — a computed size, a sampled curve — so a
standalone SVG never depends on host CSS. The same sample-an-ambient seam feeds a
chart’s fn: (with x bound to the domain — SPEC 14). Unknown names, wrong
arity, and out-of-range results are compile-time errors (SPEC 21).
A container picks an engine with layout:. Every engine reads the same core
(Part I) — the cascade, paint, text, the box model, links, colour — and
adds only its own placement algorithm, its own child roles, and a few scoped
properties. This part is the family; each section states just its delta.