Getting Started

svg-path-extended is a language that extends SVG path syntax with variables, expressions, control flow, and functions. It compiles to standard SVG path data that works in any browser or graphics application.

Your First Path

Try this simple example in the playground:

// A simple rectangle using variables
let size = 50
let x = 10
let y = 10

M x y
h size
v size
h calc(-size)
Z

This creates a rectangle by:

  1. Moving to position (10, 10)
  2. Drawing a horizontal line of length 50
  3. Drawing a vertical line of length 50
  4. Drawing a horizontal line back
  5. Closing the path

Why svg-path-extended?

SVG paths are powerful but writing them by hand is tedious:

// Standard SVG - repetitive coordinates
M 20 20 L 80 20 L 80 80 L 20 80 Z
M 100 20 L 160 20 L 160 80 L 100 80 Z
M 180 20 L 240 20 L 240 80 L 180 80 Z

With svg-path-extended, you can use variables and loops:

// svg-path-extended - DRY and readable
let size = 60
for (i in 0..3) {
  rect(calc(20 + i * 80), 20, size, size)
}

Key Features

Variables

Store and reuse values:

let width = 200
let height = 100
let centerX = calc(width / 2)

Expressions with calc()

Use math in path commands:

let r = 50
M calc(100 - r) 100
L calc(100 + r) 100

Loops

Repeat patterns easily:

for (i in 0..10) {
  circle(calc(20 + i * 30), 100, 10)
}

Functions

Define reusable shapes:

fn square(x, y, size) {
  rect(x, y, size, size)
}

square(10, 10, 50)
square(70, 10, 50)

Built-in Shapes

Common shapes are included:

circle(100, 100, 50)
rect(10, 10, 80, 60)
polygon(100, 100, 40, 6)  // hexagon
star(100, 100, 50, 25, 5)

Next Steps

  • Syntax Reference - Learn all the language features
  • Standard Library - Explore built-in functions
  • Examples - See practical patterns and recipes

Syntax Reference

svg-path-extended is a superset of SVG path syntax that adds variables, expressions, control flow, functions, and path blocks.

Path Commands

All standard SVG path commands are supported:

Command Name Parameters
M / m Move to x y
L / l Line to x y
H / h Horizontal line x
V / v Vertical line y
C / c Cubic bezier x1 y1 x2 y2 x y
S / s Smooth cubic x2 y2 x y
Q / q Quadratic bezier x1 y1 x y
T / t Smooth quadratic x y
A / a Arc rx ry rotation large-arc sweep x y
Z / z Close path (none)

Uppercase commands use absolute coordinates; lowercase use relative coordinates.

M 0 0 L 100 100 Z

Variables

Declare variables with let:

let width = 200;
let height = 100;
let centerX = 100;

Use variables directly in path commands:

let x = 50;
let y = 75;
M x y L 100 100

Note: Single letters that are path commands (M, L, C, etc.) cannot be used as variable names.

Strings and Template Literals

String values use double quotes:

let name = "World";

Template literals use backticks with ${expression} interpolation:

let greeting = `Hello ${name}!`;          // "Hello World!"
let msg = `Score: ${2 + 3}`;             // "Score: 5"
let pos = `(${ctx.position.x}, ${ctx.position.y})`;

Template literals are the sole string construction mechanism — the + operator stays strictly numeric. String equality works with == and !=:

let mode = "dark";
if (mode == "dark") { /* ... */ }
if (mode != "light") { /* ... */ }

.length

Returns the number of characters in the string:

let str = `Hello`;
log(str.length);  // 5

.empty()

Returns 1 (truthy) if the string has no characters, 0 (falsy) otherwise:

let str = ``;
if (str.empty()) {
  // string is empty
}

Index Access

Access individual characters by zero-based index using [expr]:

let str = `Hello`;
let first = str[0];   // "H"
let last = str[4];     // "o"

Out-of-bounds access throws an error.

.split()

Splits a string into an array of individual characters:

let str = `abc`;
let chars = str.split();  // ["a", "b", "c"]
for (ch in chars) {
  log(ch);
}

.append(value)

Returns a new string with the given value appended to the end:

let str = `Hello`;
let result = str.append(` World`);  // "Hello World"

.prepend(value)

Returns a new string with the given value prepended to the beginning:

let str = `World`;
let result = str.prepend(`Hello `);  // "Hello World"

.includes(substring)

Returns 1 (truthy) if the string contains the given substring, 0 (falsy) otherwise:

let str = `Hello World`;
if (str.includes(`World`)) {
  // found it
}

.slice(start, end)

Returns a substring from start (inclusive) to end (exclusive). Negative indices count from the end:

let str = `Hello World`;
let sub = str.slice(0, 5);    // "Hello"
let end = str.slice(6, 11);   // "World"
let last3 = str.slice(-3, 11); // "rld"

Color Literals

Hex color codes and CSS color functions are first-class expressions:

let c = #cc0000;                      // 6-digit hex
let c = #f00;                         // 3-digit shorthand
let c = #cc000080;                    // 8-digit with alpha
let c = rgb(255, 0, 0);              // CSS color function
let c = hsl(0, 100%, 50%);           // % is literal inside parens
let c = oklch(0.6 0.15 30);          // any CSS color space
let lighter = (#cc0000).lighten(20%); // method chaining via parens

See the Color documentation for full details.

Percent Suffix

The % suffix converts a number to a fraction: 50% becomes 0.5.

let half = 50%;          // 0.5
let third = 33.3%;       // 0.333
let c = (#ff0000).lighten(20%);  // lighten by 0.2

Disambiguation: 20% (no space) is a percent literal (= 0.2). 20 % 5 (with spaces) is the modulus operator (= 0).

Expressions with calc()

For mathematical expressions, wrap them in calc():

let r = 50;
M calc(100 - r) 100
L calc(100 + r) 100

Supported Operators

Operator Description
+ Addition
- Subtraction
* Multiplication
/ Division
% Modulo (use spaces: a % b)
< Less than
> Greater than
<= Less than or equal
>= Greater than or equal
== Equal
!= Not equal
&& Logical AND
|| Logical OR
! Logical NOT (unary)
- Negation (unary)
<< Merge (objects, style blocks, path blocks, text blocks)

Operator precedence follows standard mathematical conventions.

Style Blocks

Style blocks are CSS-like key-value maps wrapped in ${ }. They're used for layer styles but are also first-class values — you can store them in variables, merge them, and read their properties.

Literals

let styles = ${
  stroke: #cc0000;
  stroke-width: 3;
  fill: none;
};

Each property is a name: value; declaration. Values are try-evaluated as expressions — if the value parses as a valid expression (like a variable reference or calc()), its result is used. Otherwise the raw string is kept (e.g., rgb(...), #hex).

Merge (<<)

The << operator merges two values of the same type. The right side overrides the left on key conflicts:

// Style blocks
let base = ${ stroke: red; stroke-width: 2; };
let merged = base << ${ stroke-width: 4; fill: blue; };
// Result: stroke: red, stroke-width: 4, fill: blue

// Objects
let a = { x: 1, y: 2 };
let b = a << { y: 99, z: 3 };
// Result: {x: 1, y: 99, z: 3}

Multiple merges can be chained: a << b << c. See also Objects — Merging.

Property Access

Use dot notation with camelCase names to read kebab-case properties:

let s = ${ stroke-width: 4; };
let sw = s.strokeWidth;  // "4" (reads 'stroke-width')

Property values are always strings.

Usage in Layers

Style blocks are used in layer definitions and can be passed as per-element styles on text() and tspan(). See Layers for full details.

Null

The null literal represents the absence of a value. It is returned by pop() and shift() on empty arrays, and can be used in variable assignments and conditionals.

let x = null;

Truthiness

null is falsy in conditionals:

let x = null;
if (x) {
  // not reached
} else {
  M 0 0  // this branch runs
}

Equality

null is only equal to itself:

if (x == null) { /* x is null */ }
if (x != null) { /* x has a value */ }

null == 0 evaluates to 0 (false) — null is distinct from zero.

Error Behavior

Using null in arithmetic or as a path argument throws a descriptive error:

let x = null;
let y = x + 1;     // Error: Cannot use null in arithmetic expression
M x 0               // Error: Cannot use null as a path argument

Booleans

The true and false keywords represent boolean values. They are a semantic subtype of number — true is 1, false is 0 — but display as true/false in logs and template literals.

let flag = true;
let check = false;

Numeric Equivalence

Booleans participate in arithmetic as their numeric values:

true + 1     // 2
true + true  // 2
false + 1    // 1
true == 1    // true
false == 0   // true

Display

Booleans display as true or false, and comparisons return booleans:

log(true);       // true
log(5 > 3);      // true
log(1 > 5);      // false
log(`${true}`);  // true

Truthiness

false is falsy (like 0 and null); true is truthy:

if (true) { /* runs */ }
if (false) { /* skipped */ }

let result = 5 > 3;  // true (BooleanValue)
if (result) { /* runs */ }

Logical Operators

!true         // false
!false        // true
true && false // false
false || true // true

Arc Flags

Booleans can be used directly as arc flag arguments, converting to 1/0 in the SVG output:

let largeArc = true;
let sweep = false;
M 0 0 A 50 50 0 largeArc sweep 100 0
// → M 0 0 A 50 50 0 1 0 100 0

Enums

Built-in Enums

Pathogen provides built-in enums for gradient and geometry properties. Enum members resolve to the string values accepted by these properties:

Enum Members
Easing Linear, Smoothstep, EaseIn, EaseOut, EaseInOut
Interpolation SRGB, OKLCH, LinearRGB
SpreadMethod Pad, Reflect, Repeat
GradientUnits ObjectBoundingBox, UserSpaceOnUse
Direction CW, CCW
ConicSpread Clamp, Repeat, Transparent
InnerFill Transparent, TransparentBlend, Center
TopoMethod Distance, Laplace
topo.easing = Easing.Smoothstep;       // equivalent to 'smoothstep'
grad.interpolation = Interpolation.OKLCH;

Enum values are interchangeable with their string equivalents:

Easing.Linear == 'linear'  // true

User-Defined Enums

Define custom enums with enum:

// Auto-valued — member name lowercased to a string
enum Symmetry { None, Bilateral, Radial, Rotational }
log(Symmetry.Bilateral);  // bilateral

// Explicit string values
enum Season { Spring = 'vernal', Summer = 'estival' }

// Explicit typed values — number, angle, color, boolean
enum Angle { Quarter = 90deg, Half = 180deg, Full = 360deg }
enum Palette { Primary = #0066ff, Accent = #ff6600, Muted = #999 }
enum Weight { Thin = 1, Normal = 2, Bold = 4 }
enum Toggle { On = true, Off = false }

Auto-valued members always produce the lowercase string of the member name. Other types require an explicit = value.

Enum members are accessed with dot notation and can be used in conditionals:

let d = Dir.Up;
if (d == 'up') { M 10 20 }

Points

Points represent 2D coordinates and provide geometric operations for SVG path construction.

Constructor

Create a point with Point(x, y):

let center = Point(200, 200);
let origin = Point(0, 0);

Properties

Property Returns Description
.x number X coordinate
.y number Y coordinate
let pt = Point(100, 200);
M pt.x pt.y           // M 100 200
L calc(pt.x + 10) pt.y  // L 110 200

Methods

All angles are in radians, consistent with the standard library.

.translate(dx, dy)

Returns a new point offset by the given deltas:

let pt = Point(100, 100);
let moved = pt.translate(10, -20);  // Point(110, 80)

.polarTranslate(angle, distance)

Returns a new point offset by angle and distance:

let pt = Point(100, 100);
let moved = pt.polarTranslate(0, 50);     // Point(150, 100)
let up = pt.polarTranslate(-0.5pi, 30);   // 30 units upward

.midpoint(other)

Returns the midpoint between two points:

let a = Point(0, 0);
let b = Point(100, 100);
let mid = a.midpoint(b);  // Point(50, 50)

.lerp(other, t)

Linear interpolation between two points. t=0 returns this point, t=1 returns the other:

let a = Point(0, 0);
let b = Point(100, 200);
let quarter = a.lerp(b, 0.25);  // Point(25, 50)

.rotate(angle, origin)

Rotates this point around a center point:

let pt = Point(100, 0);
let center = Point(0, 0);
let rotated = pt.rotate(90deg, center);  // Point(0, 100) approximately

.distanceTo(other)

Returns the Euclidean distance between two points:

let a = Point(0, 0);
let b = Point(3, 4);
log(a.distanceTo(b));  // 5

.angleTo(other)

Returns the angle in radians from this point to another:

let a = Point(0, 0);
let b = Point(1, 0);
log(a.angleTo(b));  // 0 (pointing right)

.offset(other)

Returns an object with dx and dy properties representing the vector from this point to other. Useful for applying the same relative displacement to multiple points:

let ref = Point(200, 200);
let target = Point(100, 300);
let off = ref.offset(target);
// off.dx = -100, off.dy = 100

// Apply the same offset to a different point
let other = Point(50, 75);
M calc(other.x + off.dx) calc(other.y + off.dy)

Display

log() shows points in a readable format:

let pt = Point(100, 200);
log(pt);  // Point(100, 200)

Template Literals

Points display as Point(x, y) when interpolated in template literals:

let pt = Point(42, 99);
let msg = `position: ${pt}`;  // "position: Point(42, 99)"

Arrays

Arrays hold ordered collections of values. Elements can be numbers, strings, style blocks, other arrays, or null.

Literals

let empty = [];
let nums = [1, 2, 3];
let mixed = [10, "hello", [4, 5]];

Index Access

Access elements by zero-based index using [expr]:

let list = [10, 20, 30];
let first = list[0];         // 10
let second = list[1];        // 20
M list[0] list[1]            // M 10 20

Out-of-bounds access throws an error.

.length

Returns the number of elements:

let list = [1, 2, 3];
log(list.length);  // 3

.empty()

Returns 1 (truthy) if the array has no elements, 0 (falsy) otherwise:

let list = [];
if (list.empty()) {
  // list is empty
}

Methods

.push(value)

Appends a value to the end. Returns the new length.

let list = [1, 2];
let len = list.push(3);  // list is now [1, 2, 3], len is 3

.pop()

Removes and returns the last element. Returns null if the array is empty.

let list = [1, 2, 3];
let last = list.pop();   // last is 3, list is now [1, 2]
let empty = [];
let x = empty.pop();     // x is null

.unshift(value)

Prepends a value to the start. Returns the new length.

let list = [2, 3];
list.unshift(1);  // list is now [1, 2, 3]

.shift()

Removes and returns the first element. Returns null if the array is empty.

let list = [1, 2, 3];
let first = list.shift();  // first is 1, list is now [2, 3]

.slice(start, end?)

Returns a new array containing elements from start to end (inclusive). Negative indexes count from the end. If end is omitted, returns from start to the end of the array.

Note: Array .slice() uses inclusive end indexes, while string .slice() uses exclusive end indexes (matching JavaScript string behavior).

let arr = [10, 20, 30, 40, 50];

let mid = arr.slice(1, 3);    // [20, 30, 40] — indices 1, 2, 3
let tail = arr.slice(3);      // [40, 50]     — from index 3 to end
let last2 = arr.slice(-2);    // [40, 50]     — last 2 elements
let head = arr.slice(0, -2);  // [10, 20, 30, 40] — up to second-to-last

.map {|item| ... } / .map {|item, index, arrayRef| ... }

Transforms each element using a trailing block, returning a new array. Use return to specify the mapped value. If no return is executed, the element maps to null.

The block receives up to three parameters:

  • item — the current element
  • index (optional) — the zero-based index
  • arrayRef (optional) — a reference to the original array
let prices = [10, 25, 50];
let doubled = prices.map {|price|
  return calc(price * 2);
};
// doubled is [20, 50, 100]

// Block body supports full language features
let labels = [1, 2, 3].map {|n|
  let prefix = `item-`;
  return `${prefix}${n}`;
};
// labels is ["item-1", "item-2", "item-3"]

Use the index parameter for position-aware transforms:

let items = [10, 20, 30];
let indexed = items.map {|val, i|
  return calc(val + i);
};
// indexed is [10, 21, 32]

Use the array reference for look-ahead or look-behind:

let arr = [1, 2, 3, 4];
let pairs = arr.map {|item, idx, ref|
  if (idx < ref.length - 1) {
    return calc(item + ref[idx + 1]);
  }
  return item;
};
// pairs is [3, 5, 7, 4]

The block has access to variables from the enclosing scope:

let offset = 100;
let shifted = [1, 2, 3].map {|x|
  return calc(x + offset);
};
// shifted is [101, 102, 103]

.reduce(initialValue) {|accumulator, item, index, arrayRef| ... }

Iterates the array, threading an accumulator through each step. The initialValue argument sets the starting accumulator. The block must return the new accumulator value; if no return is executed, the accumulator becomes null.

The block receives up to four parameters:

  • accumulator — the current accumulated value
  • item (optional) — the current element
  • index (optional) — the zero-based index
  • arrayRef (optional) — a reference to the original array
let sum = [1, 2, 3, 4].reduce(0) {|acc, n|
  return calc(acc + n);
};
// sum is 10

let csv = ['a', 'b', 'c'].reduce('') {|acc, s, i|
  if (i == 0) { return s; }
  return `${acc},${s}`;
};
// csv is "a,b,c"

On an empty array, reduce returns initialValue unchanged:

let result = [].reduce(42) {|acc, n| return calc(acc + n); };
// result is 42

.mapSlice(length)

Returns a new array where each element is a sub-array (slice) of length elements starting at that element's index. Near the end of the array, slices are shorter as they extend past the bounds.

let arr = [1, 2, 3, 4];
let slices = arr.mapSlice(2);
// slices is [[1, 2], [2, 3], [3, 4], [4]]

let triples = [10, 20, 30, 40, 50].mapSlice(3);
// triples is [[10, 20, 30], [20, 30, 40], [30, 40, 50], [40, 50], [50]]

Reference Semantics

Arrays are passed by reference. Mutations through one binding are visible through all others:

let a = [1, 2, 3];
let b = a;
b.push(4);
log(a.length);  // 4 — same underlying array

For-Each Iteration

Iterate over array elements with for (item in list):

let points = [10, 20, 30];
for (p in points) {
  M p 0
}
// Produces: M 10 0 M 20 0 M 30 0

Destructure to get both item and index with for ([item, index] in list):

let sizes = [5, 10, 15];
for ([size, i] in sizes) {
  circle(calc(i * 40 + 20), 50, size)
}

Iterating over an empty array produces no output.

Angle Units

Numbers can have angle unit suffixes for convenience:

Suffix Description
45deg Degrees (converted to radians internally)
1.5rad Radians (no conversion)
0.25pi Multiplied by π (i.e. 0.25 * π)
let angle = 90deg;
M sin(45deg) cos(45deg)

// Equivalent to:
let angle = rad(90);
M sin(rad(45)) cos(rad(45))

The pi suffix multiplies the number by π. This is especially convenient for polar coordinates and angles expressed as fractions of π:

let quarter = 0.25pi;   // π/4
let half = 0.5pi;       // π/2
let full = 2pi;          // 2π
M sin(0.25pi) cos(0.25pi)

The pi suffix participates in angle unit mismatch checking: calc(0.25pi + 5) throws an error, while calc(90deg + 0.5pi) is allowed (both have angle units).

Note: The pi suffix only works on numeric literals. For expressions or variables, use mpi(x) (see Standard Library).

For Loops

Repeat path commands with for:

for (i in 0..10) {
  L calc(i * 20) calc(i * 10)
}

The range 0..10 includes both endpoints (0 through 10, giving 11 iterations).

Descending Ranges

Ranges automatically count down when start > end:

// Countdown from 5 to 1
for (i in 5..1) {
  M calc(i * 20) 0
}
// Produces: M 100 0 M 80 0 M 60 0 M 40 0 M 20 0

Nested Loops

for (row in 0..2) {
  for (col in 0..2) {
    circle(calc(col * 50 + 25), calc(row * 50 + 25), 10)
  }
}

This creates a 3x3 grid (rows 0, 1, 2 and cols 0, 1, 2).

Conditionals

Use if, else if, and else for conditional path generation:

let size = 100;

if (size > 75) {
  M 0 0 L 100 100
} else if (size > 50) {
  M 0 0 L 75 75
} else {
  M 0 0 L 50 50
}

You can chain as many else if blocks as needed. Comparison results are numeric: 1 for true, 0 for false.

Functions

Defining Functions

Create reusable path generators with fn:

fn square(x, y, size) {
  rect(x, y, size, size)
}

Calling Functions

square(10, 10, 50)
square(70, 10, 50)

Functions can call other functions and use all language features.

Comments

Line comments start with //:

// This is a comment
let x = 50;  // inline comment
M x 0

Path Context (ctx)

When using compileWithContext(), a ctx object tracks the current drawing state:

M 10 20
L 30 40
L calc(ctx.position.x + 10) ctx.position.y  // L 40 40

ctx Properties

Property Type Description
ctx.position.x number Current X coordinate
ctx.position.y number Current Y coordinate
ctx.start.x number Subpath start X (set by M, used by Z)
ctx.start.y number Subpath start Y
ctx.commands array History of executed commands

How Position Updates

  • M/m: Sets position and subpath start
  • L/l, H/h, V/v: Updates position to endpoint
  • C/c, S/s, Q/q, T/t: Updates position to curve endpoint
  • A/a: Updates position to arc endpoint
  • Z/z: Returns to subpath start

Lowercase (relative) commands add to current position; uppercase (absolute) set it directly.

log() Function

Use log() to inspect the context during evaluation:

M 10 20
log(ctx)           // Logs full context as JSON
log(ctx.position)  // Logs just position object
log(ctx.position.x) // Logs just the x value
L 30 40

The logs are captured in the logs array returned by compileWithContext().

Example: Drawing Relative to Current Position

M 100 100
L 150 150
// Continue from current position
L calc(ctx.position.x + 50) ctx.position.y
L ctx.position.x calc(ctx.position.y + 50)
Z

Complete Example

// Draw a grid of circles with varying sizes
let cols = 5;
let rows = 5;
let spacing = 40;

for (row in 0..rows) {
  for (col in 0..cols) {
    let x = calc(col * spacing + 20);
    let y = calc(row * spacing + 20);
    let r = calc(5 + col + row);
    circle(x, y, r)
  }
}

Standard Library Reference

svg-path-extended includes built-in functions for math operations and common SVG shapes.

Math Functions

Trigonometry

All trigonometric functions use radians.

Function Description
sin(x) Sine
cos(x) Cosine
tan(x) Tangent
asin(x) Arc sine
acos(x) Arc cosine
atan(x) Arc tangent
atan2(y, x) Two-argument arc tangent
// Draw a point on a circle
let angle = 0.5;
let r = 50;
M calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)

Angle Conversion

Function Description
rad(degrees) Convert degrees to radians
deg(radians) Convert radians to degrees
// Use degrees instead of radians
let angle = rad(45);
M calc(cos(angle) * 50) calc(sin(angle) * 50)

Exponential & Logarithmic

Function Description
exp(x) e raised to power x
log(x) Natural logarithm
log10(x) Base-10 logarithm
log2(x) Base-2 logarithm
pow(x, y) x raised to power y
sqrt(x) Square root
cbrt(x) Cube root

Rounding

Function Description
floor(x) Round down
ceil(x) Round up
round(x) Round to nearest integer
trunc(x) Truncate decimal part

Utility

Function Description
abs(x) Absolute value
sign(x) Sign (-1, 0, or 1)
min(a, b, ...) Minimum value
max(a, b, ...) Maximum value

Interpolation & Clamping

Function Description
lerp(a, b, t) Linear interpolation: a + (b - a) * t
clamp(value, min, max) Constrain value to range
map(value, inMin, inMax, outMin, outMax) Map value from one range to another
// Interpolate between two positions
let t = 0.5;
M calc(lerp(0, 100, t)) calc(lerp(0, 50, t))

// Clamp a value
let x = clamp(150, 0, 100);  // Result: 100

Constants

Function Returns
PI() 3.14159...
E() 2.71828...
TAU() 6.28318... (2π)
mpi(x) x * π (multiply by π)
// Draw a semicircle
let r = 50;
for (i in 0..20) {
  let angle = calc(i / 20 * PI());
  L calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)
}

Random

Function Description
random() Random number between 0 and 1
randomRange(min, max) Random number in range

Note: Random functions are not deterministic. Each call produces a different value.

Cycler

A Cycler wraps an array and cycles through it sequentially via .pick(), returning to the beginning after reaching the end. Useful for deterministic round-robin assignment of colors, layer names, styles, etc.

Cycler(array, shuffle?)

Creates a cycler from an array. If the optional shuffle argument is truthy, the array is shuffled once at construction (the shuffled order is stable across all cycles).

let c = Cycler(['red', 'green', 'blue']);
c.pick()  // 'red'
c.pick()  // 'green'
c.pick()  // 'blue'
c.pick()  // 'red' (wraps around)
// Shuffled cycler — stable order across wraps
let r = Cycler(['a', 'b', 'c'], true);

.pick()

Returns the next element in the cycle, advancing the internal index. Wraps around to the beginning after the last element.

.length

Returns the number of items in the cycler.

let c = Cycler([1, 2, 3]);
log(c.length);  // 3

PolarVector

A PolarVector represents a direction and distance in polar coordinates. It is used to define bezier control point positions relative to anchor points — you specify "which direction and how far" rather than computing absolute x, y coordinates.

PolarVector(angle, distance)

Creates a polar vector. Angle is in radians (use rad() or deg suffix for degrees).

let pv = PolarVector(0.25 * PI(), 30);
let pv2 = PolarVector(rad(45), 30);      // equivalent

.angle

Returns the angle in radians.

.distance

Returns the distance.

.turn(deltaAngle)

Returns a new PolarVector with the angle rotated by deltaAngle. Distance is unchanged.

let pv = PolarVector(0, 20);
let turned = pv.turn(0.5 * PI());  // angle is now π/2, distance still 20

.scale(factor)

Returns a new PolarVector with the distance multiplied by factor. Angle is unchanged.

let pv = PolarVector(0, 20);
let wider = pv.scale(1.5);  // angle still 0, distance is now 30

.mirror()

Returns a new PolarVector with the angle rotated by π (180°). Distance is unchanged. This is the key operation for achieving C1 (smooth) continuity when chaining bezier curves — the outgoing handle mirrors the incoming handle.

let pv = PolarVector(0.25 * PI(), 20);
let mirrored = pv.mirror();  // angle is now 1.25π, distance still 20

Path Functions

These functions generate complete path segments.

circle(cx, cy, r)

Draws a circle centered at (cx, cy) with radius r.

circle(100, 100, 50)

Output: A full circle using two arc commands.

rect(x, y, width, height)

Draws a rectangle.

rect(10, 10, 80, 60)

roundRect(x, y, width, height, radius)

Draws a rectangle with rounded corners.

roundRect(10, 10, 80, 60, 10)

polygon(cx, cy, radius, sides)

Draws a regular polygon.

polygon(100, 100, 50, 6)  // Hexagon
polygon(100, 100, 50, 8)  // Octagon

star(cx, cy, outerRadius, innerRadius, points)

Draws a star shape.

star(100, 100, 50, 25, 5)  // 5-pointed star

line(x1, y1, x2, y2)

Draws a line segment.

line(0, 0, 100, 100)

arc(rx, ry, rotation, largeArc, sweep, x, y)

Draws an arc to (x, y). This is a direct wrapper around the SVG A command.

M 50 100
arc(50, 50, 0, 1, 1, 150, 100)

quadratic(x1, y1, cx, cy, x2, y2)

Draws a quadratic bezier curve from (x1, y1) to (x2, y2) with control point (cx, cy).

quadratic(0, 100, 50, 0, 100, 100)

cubic(x1, y1, c1x, c1y, c2x, c2y, x2, y2)

Draws a cubic bezier curve.

cubic(0, 100, 25, 0, 75, 0, 100, 100)

polarCubicBezier(start, pv1, pv2, end)

Draws a cubic bezier curve where control points are defined as polar vectors relative to the start and end points. start and end are Point values; pv1 and pv2 are PolarVector values.

  • pv1 — direction and distance from start to the first control point
  • pv2 — direction and distance from end to the second control point
let a = Point(0, 100);
let b = Point(100, 100);
polarCubicBezier(a, PolarVector(rad(-60), 40), PolarVector(rad(-120), 40), b)

Output: m (relative move) followed by c (relative cubic) — matches the spline function convention.

PolarVector methods compose naturally for handle manipulation:

let handle = PolarVector(rad(-45), 30);
// Symmetric curve: mirror the handle for the other end
polarCubicBezier(a, handle, handle.mirror(), b)

// Wider version: scale the handle distance
polarCubicBezier(a, handle.scale(1.5), handle.mirror().scale(1.5), b)

moveTo(x, y)

Returns a move command. Useful inside functions.

moveTo(50, 50)

lineTo(x, y)

Returns a line command.

lineTo(100, 100)

closePath()

Returns a close path command.

closePath()

cubicSpline(points)

Draws a chain of cubic bezier curves with explicit tangent angle and handle length at each point. Adjacent curves share a common tangent direction at join points, guaranteeing G1 (smooth) continuity.

Point schema:

Property Type Description
x number X coordinate
y number Y coordinate
angle number Tangent angle (radians; use rad() or deg suffix for degrees)
exit number Distance from point along tangent to outgoing control point (omit on last point)
entry number Distance backward along tangent to incoming control point (omit on first point)
cubicSpline([
  { x: 0, y: 100, angle: 0, exit: 30 },
  { x: 50, y: 0, angle: 0, entry: 20, exit: 25 },
  { x: 100, y: 100, angle: 0, entry: 30 }
])

Output: m (relative move) followed by one c (relative cubic) command per segment. A single-point array emits only m. All spline functions use relative commands so they work naturally inside path blocks.

quadSpline(start, points, end)

Draws a chain of quadratic bezier curves with implicit angle derivation. Only the start point specifies an explicit angle; intermediate points derive their tangent angle from the geometry of the previous control point.

Start: { x, y, angle, exit } Intermediate: { x, y, exit } End: { x, y }

quadSpline(
  { x: 0, y: 0, angle: 0, exit: 30 },
  [{ x: 60, y: 0, exit: 30 }],
  { x: 120, y: 0 }
)

Output: m followed by one q (relative quadratic) command per segment.

clippedQuadSpline(start, points, end)

Extends quadSpline by splitting the implicit shared control point into two cubic control points using time-based fractions (exitTime/entryTime). This allows dampening curve eccentricity while preserving the quadratic geometry.

Start: { x, y, angle, exit, exitTime } Intermediate: { x, y, exit, exitTime, entryTime } End: { x, y, entryTime }

  • exitTime = 1, entryTime = 1: mathematically equivalent to quadratic
  • exitTime = 0.5, entryTime = 0.5: control points at half arm length (moderate dampening)
  • exitTime = 0, entryTime = 0: linear segments
clippedQuadSpline(
  { x: 0, y: 0, angle: 0, exit: 100, exitTime: 0.5 },
  [],
  { x: 200, y: 0, entryTime: 0.5 }
)

Output: m followed by one c (relative cubic) command per segment — not q.

Grid Functions

These functions generate complete grid patterns as path segments. Each accepts a GridPatternType enum (or string) that controls the visual style:

Pattern Description
GridPatternType.Shape ('shape') Cell outlines — full grid lines
GridPatternType.Dot ('dot') Small circles at grid vertices
GridPatternType.Intersection ('intersection') Small cross marks at grid vertices
GridPatternType.Partial ('partial') Centered partial segments on each edge

squareGrid(type, x, y, width, height, cellSize)

Generates a square grid pattern within the bounding rectangle starting at (x, y).

  • typeGridPatternType enum value or string ('shape', 'dot', 'intersection', 'partial')
  • x, y — Top-left origin of the grid
  • width, height — Bounding dimensions
  • cellSize — Side length of each square cell

The grid contains floor(width / cellSize) columns and floor(height / cellSize) rows. Extra space is ignored.

gridLayer.apply {
  squareGrid(GridPatternType.Shape, 0, 0, 200, 200, 20);
}

triangleGrid(type, x, y, width, height, cellSize)

Generates an equilateral triangle grid. cellSize is the triangle height (altitude). Triangles have flat bases with alternating up/down orientation.

gridLayer.apply {
  triangleGrid(GridPatternType.Shape, 0, 0, 200, 200, 20);
}

The triangle side length is derived from the height: side = 2 * cellSize / sqrt(3).

hexagonGrid(type, x, y, width, height, cellSize, orientation?)

Generates a hexagonal grid. cellSize is the flat-to-flat height of each hexagon.

  • orientation — Optional. HexagonOrientation.Edge (default, flat-top) or HexagonOrientation.Vertex (pointy-top)
// Flat-top hexagons (default)
gridLayer.apply {
  hexagonGrid(GridPatternType.Shape, 0, 0, 200, 200, 20);
}

// Pointy-top hexagons
gridLayer.apply {
  hexagonGrid(GridPatternType.Shape, 0, 0, 200, 200, 20, HexagonOrientation.Vertex);
}

Usage with Layers and Transforms

Grid functions return path data and are typically used inside layer.apply {} blocks. Rotation and styling are handled via the layer:

let gridStyles = ${ stroke: #88f; stroke-width: 0.25; fill: none; };
let gridLayer = PathLayer('grid') << gridStyles;

gridLayer.ctx.transform.rotate.set(0.125pi);
gridLayer.apply {
  squareGrid(GridPatternType.Partial, 0, 0, 400, 400, 20);
}

A convenience wrapper for one-line grid drawing:

fn drawGridToLayer(layer, gridFn, type, angle, x, y, w, h, s) {
  layer.ctx.transform.rotate.set(angle);
  layer.apply { gridFn(type, x, y, w, h, s); }
}

Context-Aware Functions

These functions use the current path context (position, tangent direction) to generate path segments. They maintain path continuity and are ideal for building complex shapes programmatically.

Polar Movement

polarPoint(angle, distance)

Returns a point at a polar offset from current position. Does not emit any path commands.

M 100 100
let p = polarPoint(0, 50);
L p.x p.y  // Line to (150, 100)

polarOffset(angle, distance)

Returns {x, y} coordinates at a polar offset. Similar to polarPoint.

polarMove(angle, distance)

Emits a line command (L) moving in the specified direction. Updates position but draws a visible line.

M 100 100
polarMove(0, 50)  // Draws line to (150, 100)

polarLine(angle, distance)

Emits a line command (L) in the specified direction. Same as polarMove.

M 100 100
polarLine(45deg, 70.7)  // Draws line diagonally

Arc Functions

arcFromCenter(dcx, dcy, radius, startAngle, endAngle, clockwise)

Draws an arc defined by center offset and angles. Returns {point, angle} with endpoint and tangent.

  • dcx, dcy: Offset from current position to arc center
  • radius: Arc radius
  • startAngle, endAngle: Start and end angles in radians
  • clockwise: 1 for clockwise, 0 for counter-clockwise

Warning: If current position doesn't match the calculated arc start point, a line segment (L) will be drawn to the arc start. For guaranteed continuous arcs, use arcFromPolarOffset.

M 50 50
arcFromCenter(50, 0, 50, 180deg, 270deg, 1)
// Center at (100, 50), arc from (50, 50) to (100, 100)

arcFromPolarOffset(angle, radius, angleOfArc)

Draws an arc where the center is at a polar offset from current position. The current position is guaranteed to be on the circle, so only an A command is emitted (no M or L). Returns {point, angle} with endpoint and tangent.

  • angle: Direction from current position to arc center (radians)
  • radius: Arc radius
  • angleOfArc: Sweep angle (positive = clockwise, negative = counter-clockwise)

This function is ideal for creating continuous curved paths because it never emits extra line segments.

M 100 100
arcFromPolarOffset(0, 50, 90deg)
// Center at (150, 100), sweeps 90° clockwise
// Ends at (150, 50)

Comparison with arcFromCenter:

Aspect arcFromCenter arcFromPolarOffset
Center defined by Offset from current position Polar direction from current position
Start point Calculated from startAngle Current position (guaranteed)
May emit L command Yes, if position doesn't match Never
Best for Arcs with known center offset Continuous curved paths

Heading Control

Angles follow SVG coordinate conventions: 0 is rightward, positive angles rotate clockwise (toward the positive y-axis, which points down in SVG).

heading(angle)

Sets the heading to an absolute angle. No command is emitted and the cursor does not move. This enables tangentArc and tangentLine immediately after M without needing a dummy segment like h 0.01.

M 50 100
heading(0)           // Set heading to rightward
tangentArc(20, 90deg) // Works immediately — no dummy segment needed

Inside path blocks, heading() avoids the offset artifacts that h 0.01 causes with z closePath:

let cLike = @{
  heading(0)
  tangentArc(20, 90deg)
  tangentArc(20, -90deg)
  z  // Closes cleanly to start — no tiny offset
};

turn(delta)

Adds delta to the current heading (relative change). Requires an existing heading — either from heading() or from a previous drawing command. Negative deltas turn counter-clockwise.

M 50 100
heading(0)          // Start heading rightward
turn(90deg)         // Now heading downward
tangentLine(30)     // Draws 30px down

After drawing commands:

M 0 0  L 50 0      // Heading is 0 (rightward)
turn(45deg)         // Heading is now 45°
tangentLine(20)     // Continues at 45°

ctx.heading

The current heading (read-only), readable via the context object. Set by heading(), turn(), or any drawing command that establishes direction. M (moveTo) clears the heading.

M 0 0  L 50 0
log(ctx.heading)   // 0 (rightward)
heading(90deg)
log(ctx.heading)   // π/2 (downward)
M 200 200
log(ctx.heading)   // undefined (M clears the heading)

Tangent Functions

These functions continue from the current heading. Any path command that establishes a direction — including native SVG commands (L, H, V, C, S, Q, T, A, Z) and stdlib path functions — sets a heading that tangentLine and tangentArc can follow.

You can also set the heading explicitly with heading(), adjust it with turn(), or read it via ctx.heading.

M (moveTo) clears the heading since a move does not establish a direction.

tangentLine(length)

Draws a line continuing in the tangent direction from the previous command.

arcFromPolarOffset(0, 50, 90deg)
tangentLine(30)  // Continues in the arc's exit direction

After native SVG commands:

M 50 100  L 150 100
tangentLine(30)  // Continues rightward to (180, 100)

tangentArc(radius, sweepAngle)

Draws an arc continuing tangent to the previous command.

arcFromPolarOffset(0, 50, 90deg)
tangentArc(30, 45deg)  // Smooth continuation with a smaller arc

After native SVG commands:

M 50 100  L 150 100
tangentArc(30, 90deg)  // Smooth arc curving down from the line's endpoint


Color

The Color type provides first-class color manipulation in OKLCH color space. See the full Color documentation for constructor forms, methods, properties, and examples.

let c = Color('#e63946');
let lighter = c.lighten(0.2);
let comp = c.complement();

CSSVar

The CSSVar type creates CSS custom property references (var()) for use in style blocks. See the full CSSVar documentation for constructor forms, properties, and examples.

let fg = CSSVar('--foreground', '#333');
define PathLayer('main') ${ stroke: fg; }

Using Functions Inside calc()

Math functions can be used inside calc():

M calc(sin(0.5) * 100) calc(cos(0.5) * 100)
L calc(lerp(0, 100, 0.5)) calc(clamp(150, 0, 100))

Path functions are called at the statement level:

circle(100, 100, calc(25 + 25))  // calc() inside arguments

Layers

Layers let you output multiple <path> elements from a single program, each with its own styles and independent pen tracking.

Defining Layers

Use define to create a named layer with a style block:

define PathLayer('outline') ${
  stroke: #cc0000;
  stroke-width: 3;
  fill: none;
}

Layer names must be unique strings. The style block uses CSS/SVG property syntax — any SVG presentation attribute works (stroke, fill, opacity, stroke-dasharray, etc.).

Breaking change: Style blocks now use ${ } syntax instead of { }. Update existing layer definitions: { stroke: red; }${ stroke: red; }.

Default Layer

Mark one layer as default to receive all bare path commands (commands outside any layer().apply block):

define default PathLayer('main') ${
  stroke: #333;
  stroke-width: 2;
  fill: none;
}

// These commands go to 'main' automatically
M 10 10
L 90 10
L 90 90
Z

Without a default layer, bare commands go to an implicit unnamed layer.

Writing to Layers

Use layer('name').apply { ... } to send commands to a specific layer:

define PathLayer('grid') ${
  stroke: #ddd;
  stroke-width: 0.5;
}

define default PathLayer('shape') ${
  stroke: #333;
  stroke-width: 2;
  fill: none;
}

// Draw a grid on the 'grid' layer
layer('grid').apply {
  for (i in 0..10) {
    M calc(i * 20) 0
    V 200
    M 0 calc(i * 20)
    H 200
  }
}

// These go to 'shape' (the default)
M 40 40
L 160 40
L 100 160
Z

Context Isolation

Each layer has its own pen position. Commands in one layer don't affect another layer's ctx:

define default PathLayer('a') ${ stroke: red; }
define PathLayer('b') ${ stroke: blue; }

M 100 100    // layer 'a' position: (100, 100)

layer('b').apply {
  M 50 50    // layer 'b' position: (50, 50)
}

// Back in layer 'a', position is still (100, 100)
L 200 200

Accessing Layer Context

Use layer('name').ctx to read a layer's pen state from anywhere:

define default PathLayer('main') ${ stroke: #333; }
define PathLayer('markers') ${ stroke: red; fill: red; }

M 50 50
L 150 80
L 100 150

// Draw markers at the main layer's current position
layer('markers').apply {
  let px = layer('main').ctx.position.x
  let py = layer('main').ctx.position.y
  circle(px, py, 4)
}

Available context properties:

Expression Description
layer('name').ctx.position.x Current X position
layer('name').ctx.position.y Current Y position
layer('name').ctx.start.x Subpath start X
layer('name').ctx.start.y Subpath start Y
layer('name').name Layer name string

Dynamic Layer Names

Layer names can be expressions, including variables:

let target = 'overlay'
define PathLayer(target) ${ stroke: blue; }

layer(target).apply {
  M 0 0 L 100 100
}

Dynamic Layer Creation

Layers can also be created as first-class values using PathLayer() and TextLayer() constructor expressions. This allows storing layers in variables, appending styles after creation, and using .apply { } directly on the variable.

Constructor Expression

let myLayer = PathLayer('unique-name') ${ stroke: red; fill: none; };
myLayer.apply { M 0 0 L 100 100 }

The style block is optional:

let myLayer = PathLayer('unique-name');
myLayer.apply { M 0 0 }

Style Mutation with <<

The << operator on a layer reference merges styles in place and returns the reference for chaining:

let l = PathLayer('outline');
l << ${ stroke: red; } << ${ fill: blue; };
l.apply { M 0 0 L 100 100 }
// l.styles: stroke: red, fill: blue

Explicit .styles Property

Read or replace a layer's styles via the .styles property:

let l = PathLayer('outline') ${ stroke: red; };

// Read: returns a StyleBlockValue copy
let s = l.styles;
log(s.stroke)  // "red"

// Write: replaces all styles
l.styles = l.styles << ${ fill: blue; };

TextLayer Constructor

let labels = TextLayer('labels') ${ font-size: 14; font-family: monospace; };
labels.apply { text(50, 45)`Start` }

Accessing Layer Properties

Dynamic layers support the same properties as layer() references:

Expression Description
myLayer.name Layer name string
myLayer.ctx Path context (PathLayer only)
myLayer.styles Style block (read/write)

Coexistence with define

Both approaches work together. The define syntax supports the default modifier; dynamic constructors do not:

define default PathLayer('main') ${ stroke: #333; fill: none; }
let overlay = PathLayer('overlay') ${ stroke: red; };

M 10 10 L 90 90          // goes to 'main' (default)
overlay.apply { M 50 50 L 60 60 }

// layer() function works for both:
layer('overlay').apply { M 70 70 }

Layers render in definition order regardless of how they were created.

Style Properties

Style properties map directly to SVG presentation attributes. Common properties:

Property Example Description
stroke #cc0000 Stroke color
stroke-width 3 Stroke width
stroke-linecap round Line cap style
stroke-linejoin round Line join style
stroke-dasharray 4 2 Dash pattern
stroke-dashoffset 1 Dash offset
stroke-opacity 0.5 Stroke opacity
fill none Fill color
fill-opacity 0.3 Fill opacity
opacity 0.8 Overall opacity

Each property is a semicolon-terminated declaration:

define PathLayer('dashed') ${
  stroke: #0066cc;
  stroke-width: 2;
  stroke-dasharray: 8 4;
  fill: none;
}

Output Format

When using the JavaScript API, compile() returns a structured result:

import { compile } from 'svg-path-extended';

const result = compile(`
  define default PathLayer('bg') ${
    stroke: #ddd;
    fill: none;
  }
  define PathLayer('fg') ${
    stroke: #333;
    stroke-width: 2;
    fill: none;
  }

  M 0 0 H 100 V 100 H 0 Z

  layer('fg').apply {
    M 20 20 L 80 80
  }
`);

// result.layers is an array of LayerOutput:
// [
//   {
//     name: 'bg',
//     type: 'path',
//     data: 'M 0 0 H 100 V 100 H 0 Z',
//     styles: { stroke: '#ddd', fill: 'none' },
//     isDefault: true
//   },
//   {
//     name: 'fg',
//     type: 'path',
//     data: 'M 20 20 L 80 80',
//     styles: { stroke: '#333', 'stroke-width': '2', fill: 'none' },
//     isDefault: false
//   }
// ]

Programs without any define statements produce a single implicit layer:

compile('M 0 0 L 100 100').layers
// [{ name: 'default', type: 'path', data: 'M 0 0 L 100 100', styles: {}, isDefault: true }]

Full Example

A multi-layer illustration with a background grid, main shape, and annotation markers:

// Layer definitions
define PathLayer('grid') ${
  stroke: #e0e0e0;
  stroke-width: 0.5;
}

define default PathLayer('shape') ${
  stroke: #333333;
  stroke-width: 2;
  fill: none;
  stroke-linejoin: round;
}

define PathLayer('points') ${
  stroke: #cc0000;
  fill: #cc0000;
}

// Grid
layer('grid').apply {
  for (i in 0..10) {
    M calc(i * 20) 0  V 200
    M 0 calc(i * 20)  H 200
  }
}

// Shape (goes to default layer)
let cx = 100
let cy = 100
let r = 60
let sides = 6

for (i in 0..sides) {
  let angle = calc(i * 360 / sides - 90)
  let x = calc(cx + r * cos(radians(angle)))
  let y = calc(cy + r * sin(radians(angle)))
  if (i == 0) { M x y } else { L x y }
}
Z

// Mark each vertex
layer('points').apply {
  for (i in 0..sides) {
    let angle = calc(i * 360 / sides - 90)
    let x = calc(cx + r * cos(radians(angle)))
    let y = calc(cy + r * sin(radians(angle)))
    circle(x, y, 3)
  }
}

TextLayer

TextLayers produce SVG <text> elements instead of <path> elements.

Defining a TextLayer

define TextLayer('labels') ${
  font-size: 14;
  font-family: monospace;
  fill: #333;
}

text() — Two Forms

Inline form — simple text content:

layer('labels').apply {
  text(50, 45)`Start`
  text(150, 75, 30deg)`End`    // rotation uses angle units (deg/rad/pi)
}

Block form — mixed text runs and tspan children:

layer('labels').apply {
  text(10, 180) {
    `Hello `
    tspan(0, 0, 30deg)`world`
    ` and more`
  }
}

The block form maps to SVG's mixed content model: <text x="10" y="180">Hello <tspan rotate="30">world</tspan> and more</text>

Note: 30deg in the source becomes rotate="30" (degrees) in SVG output.

tspan() — Only Inside text() Blocks

tspan()`content`                   // no offset
tspan(dx, dy)`content`             // with offsets
tspan(dx, dy, 45deg)`content`      // with offsets and rotation

Position arguments (x, y, dx, dy) are plain numbers. Rotation follows the standard angle unit convention — bare numbers are radians, use deg/rad/pi suffixes for explicit units. Content is always a template literal.

Template Literals

Template literals use backtick syntax with ${expression} interpolation. They work everywhere — text content, log messages, variable values:

let name = "World"
let x = `Hello ${name}!`              // "Hello World!"
let msg = `Score: ${2 + 3}`           // "Score: 5"
log(`Position: ${ctx.position.x}`)    // in log messages

Template literals are the sole string construction mechanism — + stays strictly numeric. String equality (==/!=) works for conditionals:

let mode = "dark"
if (mode == "dark") { /* ... */ }
if (mode != "light") { /* ... */ }

TextLayer Output Format

const result = compile(`
  define TextLayer('labels') ${ font-size: 14; fill: #333; }
  layer('labels').apply {
    text(50, 45)\`Start\`
    text(10, 180) {
      tspan()\`Multi-\`
      tspan(0, 16)\`line\`
    }
  }
`);

// result.layers[0]:
// {
//   name: 'labels',
//   type: 'text',
//   data: 'Start Multi-line',
//   textElements: [
//     { x: 50, y: 45, children: [{ type: 'run', text: 'Start' }] },
//     { x: 10, y: 180, children: [
//       { type: 'tspan', text: 'Multi-' },
//       { type: 'tspan', text: 'line', dx: 0, dy: 16 },
//     ]},
//   ],
//   styles: { 'font-size': '14', fill: '#333' },
//   isDefault: false,
// }

Restrictions

  • text() can only be used inside a layer().apply block targeting a TextLayer
  • tspan() can only appear inside a text() { } block
  • Path commands (M, L, etc.) cannot be used inside a TextLayer apply block
  • If a TextLayer is the default layer, bare path commands will throw an error

Style Blocks

Style blocks are first-class values that can be stored in variables, merged, and accessed via dot notation.

Style Block Literals

let styles = ${
  stroke-dasharray: 0.01 20;
  stroke-linecap: round;
  stroke-width: 8.4;
};

Merge Operator (<<)

The << operator merges two style blocks, with the right side overriding the left:

let base = ${ stroke: red; stroke-width: 2; };
let merged = base << ${ stroke-width: 4; fill: blue; };
// merged has: stroke: red, stroke-width: 4, fill: blue

Property Access

Use dot notation with camelCase to read kebab-case properties:

let styles = ${ stroke-width: 4; };
let sw = styles.strokeWidth;  // reads 'stroke-width' → "4"

Expression Evaluation in Values

Style block values are try-evaluated: if a value parses and evaluates as an expression, its result is used. Otherwise the raw string is kept:

let dynamic = ${
  font-size: calc(12 + 15);       // evaluates to "27"
  stroke-width: randomRange(2, 8); // evaluates to a random number
  stroke: rgb(232, 74, 166);       // kept as raw string
  fill: #996633;                   // kept as raw string
};

Layer Definitions with Style Expressions

Layer definitions accept any expression that evaluates to a style block:

let baseStyles = ${ stroke: red; stroke-width: 2; };
define PathLayer('main') baseStyles << ${ fill: none; }

Per-Element Styles on Text and Tspan

Pass style blocks as the 4th argument to text() or tspan():

let bold = ${ font-weight: bold; };
layer('labels').apply {
  text(10, 20, 0, bold)`Hello`
  text(50, 80) {
    tspan(0, 0, 0, ${ fill: red; })`colored`
  }
}

Transforms

Apply SVG matrix transformations (translate, rotate, scale) at the layer level. Transforms are set via method calls on ctx.transform and rendered as SVG transform attributes on the output elements.

Translate

define PathLayer('shape') ${ stroke: #333; fill: none; }

layer('shape').ctx.transform.translate.set(50, 50)

layer('shape').apply {
  M 0 0 L 100 0 L 100 100 Z
}
// Output: <path d="..." transform="translate(50, 50)"/>

Rotate

Angles are in radians (consistent with polar commands). Use deg suffix for degrees:

layer('shape').ctx.transform.rotate.set(45deg)         // around origin
layer('shape').ctx.transform.rotate.set(45deg, 50, 50) // around (50, 50)

Scale

layer('shape').ctx.transform.scale.set(2, 2)             // uniform scale
layer('shape').ctx.transform.scale.set(2, 2, 50, 50)     // scale around (50, 50)

Reset

layer('shape').ctx.transform.translate.reset()  // clear translate only
layer('shape').ctx.transform.rotate.reset()     // clear rotate only
layer('shape').ctx.transform.scale.reset()      // clear scale only
layer('shape').ctx.transform.reset()            // clear all transforms

Read Access

layer('shape').ctx.transform.translate.x    // 0 if not set
layer('shape').ctx.transform.translate.y
layer('shape').ctx.transform.rotate.angle   // 0 if not set
layer('shape').ctx.transform.scale.x        // 1 if not set (default scale)
layer('shape').ctx.transform.scale.y        // 1 if not set

Default Context (No Layers)

When no layers are defined, use ctx.transform directly:

ctx.transform.translate.set(25, 25)
ctx.transform.rotate.set(45deg)
M 0 0 L 100 0

Inside Apply Blocks

Inside a layer().apply block, ctx refers to the active layer's context:

layer('shape').apply {
  ctx.transform.translate.set(10, 20)
  M 0 0 L 50 50
}

Combined Transforms

When multiple transforms are set, they are applied in SVG order: translate → rotate → scale (translate applied last visually):

layer('shape').ctx.transform.translate.set(10, 20)
layer('shape').ctx.transform.rotate.set(90deg)
layer('shape').ctx.transform.scale.set(2, 2)
// Output: transform="translate(10, 20) rotate(90) scale(2, 2)"

Transform Convenience Properties

Style blocks support individual transform properties as an alternative to transform: ... or the imperative API. These work on PathLayer, GroupLayer, and TextLayer:

define PathLayer('p') ${
  translate-x: 50;
  translate-y: 100;
  scale-x: 2;
  scale-y: 2;
  rotate: 0.25pi;
}
// Output: transform="translate(50, 100) rotate(45) scale(2, 2)"

Shorthands for translate and scale accept comma-separated values:

define PathLayer('p') ${ translate: 50, 100; scale: 2, 3; }
// Output: transform="translate(50, 100) scale(2, 3)"

Single-value scale uses the same value for both axes:

define PathLayer('p') ${ scale: 2; }
// Output: transform="scale(2, 2)"

The rotate value is an expression in radians (angle units like deg and pi work normally):

define PathLayer('p') ${ rotate: 45deg; }
// Output: transform="rotate(45)"

Precedence: An explicit transform property overrides convenience properties. Convenience properties override imperative ctx.transform calls. The individual translate-x/translate-y properties override the translate shorthand (and similarly for scale).

Convenience properties are removed from the output styles — they only affect the transform attribute.

Per-Layer Isolation

Each layer has independent transforms — setting a transform on one layer does not affect others:

define PathLayer('a') ${ stroke: red; }
define PathLayer('b') ${ stroke: blue; }

layer('a').ctx.transform.translate.set(10, 10)
layer('b').ctx.transform.scale.set(2, 2)
// Layer 'a' gets translate(10, 10), layer 'b' gets scale(2, 2)

GroupLayer

GroupLayers map to SVG <g> elements and organize child layers via .append(). They support transforms through style blocks and the imperative ctx.transform API, but do not support apply blocks.

Definition

// Define a group with styles
let panel = GroupLayer('panel') ${ opacity: 0.8; };

// Or with define (cannot be default)
define GroupLayer('panel') ${ opacity: 0.8; }

GroupLayers cannot be the default layer — define default GroupLayer(...) is an error.

Adding Children with .append()

Use .append(ref1, ref2, ...) to add layers as children of a group. All arguments must be layer references:

let panel = GroupLayer('panel') ${};
let bg = PathLayer('bg') ${ fill: #eee; };
bg.apply { rect(0, 0, 200, 200) }

let label = TextLayer('label') ${ font-size: 14; fill: #333; };
label.apply { text(10, 20)`Panel Title` }

// Append children to group
panel.append(bg, label)

Output SVG:

<g>
  <path d="..." fill="#eee" .../>
  <text x="10" y="20" font-size="14" fill="#333">Panel Title</text>
</g>

Appended layers are removed from the top-level output and rendered inside the group.

Nesting Groups

Groups can contain other groups, up to a maximum nesting depth of 10:

let inner = GroupLayer('inner') ${};
let child = PathLayer('child') ${};
child.apply { M 5 5 }
inner.append(child)

let outer = GroupLayer('outer') ${};
outer.append(inner)

Transforms

GroupLayers support both style block transforms and imperative transforms:

// Style block transform
let panel = GroupLayer('panel') ${ transform: translate(50, 100); };

// Imperative transform
panel.ctx.transform.rotate.set(0.785)
panel.ctx.transform.scale.set(2, 2)

When both are present, the style block transform takes precedence.

Moving Layers Between Groups

Appending a layer that already belongs to another group moves it. A warning log is emitted:

let g1 = GroupLayer('g1') ${};
let g2 = GroupLayer('g2') ${};
let child = PathLayer('child') ${};
g1.append(child)  // child is in g1
g2.append(child)  // child moves to g2, warning logged

No Apply Blocks

GroupLayers do not support .apply blocks. Use .append() to add children:

// This is an error:
// g.apply { M 0 0 }

// Use .append() instead:
g.append(myPath)

Limitations

  • No nesting apply blockslayer().apply blocks cannot be nested inside each other
  • Layer order — layers render in definition order (first defined = bottom)
  • GroupLayer nesting — maximum depth of 10 levels
  • PathLayer transforms only — transforms are currently available on PathLayers and GroupLayers via ctx.transform; TextLayer transform support can be added later

Path Blocks

Path Blocks let you define reusable, introspectable paths without immediately drawing them. A PathBlock captures relative path commands and exposes metadata (length, vertices, endpoints) for positioning other elements relative to the path.

Syntax

let myPath = @{
  v 20
  h 30
  v -20
};

@{ opens a Path Block, } closes it. The body contains relative path commands, control flow, variables, and function calls. The result is a PathBlock value — no path commands are emitted.

Drawing a Path Block

Use .draw() to emit the path's commands at the current cursor position:

let shape = @{ v 20 h 20 v -20 z };

M 10 10
shape.draw()     // emits: v 20 h 20 v -20 z
M 50 50
shape.draw()     // reuse at a different position

draw() advances the cursor to the path's endpoint and returns a ProjectedPath with absolute coordinates.

Assigning the draw result

let shape = @{ v 20 h 20 };
M 10 10
let proj = shape.draw();
// proj.startPoint = Point(10, 10)
// proj.endPoint = Point(30, 30)

Drawing at a specific position

Use .drawTo(x, y) to emit M x y followed by the path's commands in a single call. This combines positioning and drawing — no separate M command needed.

let shape = @{ v 20 h 20 v -20 z };

shape.drawTo(10, 10)     // emits: M 10 10 v 20 h 20 v -20 z
shape.drawTo(50, 50)     // reuse at a different position

drawTo() returns a ProjectedPath with absolute coordinates, just like draw():

let shape = @{ v 20 h 30 };
let proj = shape.drawTo(10, 10);
// proj.startPoint = Point(10, 10)
// proj.endPoint = Point(40, 30)

drawTo() also works on ProjectedPath values — it re-positions the projected path to the new origin:

let shape = @{ h 50 v 30 };
let proj = shape.project(0, 0);
proj.drawTo(100, 100)    // emits: M 100 100 h 50 v 30

Projecting Without Drawing

Use .project(x, y) to compute absolute coordinates without emitting commands or moving the cursor:

let shape = @{ v 20 h 30 };
let proj = shape.project(10, 10);
// proj.startPoint = Point(10, 10)
// proj.endPoint = Point(40, 30)
// No path commands emitted, cursor unchanged

Properties

PathBlock

Property Type Description
length number Total arc-length of the path
vertices Point[] Unique start/end points of each command segment
subPathCount number Number of subpaths (separated by m commands)
subPathCommands object[] Structured command list (see below)
startPoint Point Always Point(0, 0)
endPoint Point Final cursor position (relative to origin)

ProjectedPath

Same properties as PathBlock but with absolute coordinates.

subPathCommands entries

Each entry in subPathCommands is an object with:

{
  command: "v",           // lowercase command letter
  args: [20],             // numeric arguments
  start: Point(0, 0),     // cursor before command
  end: Point(0, 20)       // cursor after command
}

Control Flow Inside Path Blocks

Variables, for loops, foreach loops, if statements, and function calls all work inside path blocks:

let zigzag = @{
  for (i in 0..4) {
    v 10
    h calc(i % 2 == 0 ? 10 : -10)
  }
};

Context-aware functions like arcFromPolarOffset, tangentLine, and tangentArc work against the block's temporary path context.

Accessing Outer Variables

Path blocks can read variables from enclosing scope:

let size = 20;
let box = @{ v size h size v calc(-size) z };

First-Class Values

PathBlocks can be passed as function arguments and returned from functions:

fn makeStep(dx, dy) {
  return @{ h dx v dy };
}

let step = makeStep(10, 5);
M 0 0
step.draw()    // emits: h 10 v 5

Using Path Metadata

Access path properties for layout calculations:

let segment = @{ v 20 h 30 };

// Use length to create a matching horizontal line
let total = segment.length;       // 50

// Use endpoint for positioning
let end = segment.endPoint;       // Point(30, 20)
M end.x end.y                     // Position at path endpoint

Restrictions

Path blocks enforce these rules at runtime:

  1. Relative commands only — All path commands must be lowercase (m, l, h, v, etc.). Uppercase (absolute) commands throw an error.
  2. No layer definitionsdefine PathLayer/TextLayer is not allowed
  3. No layer apply blockslayer().apply { } is not allowed
  4. No text statementstext() / tspan() are not allowed
  5. No nesting — Path blocks cannot contain other @{ } expressions
  6. No draw/project inside blocks — Calling .draw() or .project() inside a path block throws an error

Parametric Sampling

Parametric sampling lets you query points, tangent directions, and normal directions at any position along a path. The parameter t is a fraction from 0 (start) to 1 (end) measured by arc length.

These methods work on both PathBlock values and ProjectedPath values.

get(t) → Point

Returns the point at arc-length fraction t along the path.

let p = @{ v 50 h 100 };
let mid = p.get(0.5);       // Point roughly at distance 75 along path
M mid.x mid.y               // position at midpoint

tangent(t){ point, angle }

Returns the point and tangent angle (radians) at fraction t. The angle is the direction of travel.

let p = @{ v 50 h 100 };
let tan = p.tangent(0.0);
log(tan.point);              // Point(0, 0)
log(tan.angle);              // ~1.5708 (π/2, pointing down)

normal(t){ point, angle }

Returns the point and left-hand normal angle at fraction t. The normal angle equals the tangent angle minus π/2.

let p = @{ h 100 };
let n = p.normal(0.5);
log(n.point);                // Point(50, 0)
log(n.angle);                // ~-1.5708 (pointing up — left-hand normal of rightward path)

partition(n) → OrientedPoint[]

Divides the path into n equal-length segments, returning n + 1 oriented points (endpoints inclusive). Each oriented point has point, angle, and t properties.

Property Type Description
point Point Position at this sample
angle number Tangent angle (radians)
t number Arc-length fraction (i / n)
let p = @{ h 100 };
let pts = p.partition(4);    // 5 points at x = 0, 25, 50, 75, 100
for (op in pts) {
  log(op.point.x, op.angle, op.t);
}
// t values: 0, 0.25, 0.5, 0.75, 1

Sampling on ProjectedPath

Projected paths return absolute coordinates:

let p = @{ h 100 };
let proj = p.project(10, 20);
let mid = proj.get(0.5);    // Point(60, 20) — offset by projection origin

Curve Support

Sampling works on all command types including cubic/quadratic Bézier curves and arcs. Curves use arc-length parameterization so that t = 0.5 always represents the geometric midpoint, not the parametric midpoint.

Transforms

Transforms create new paths from existing ones — reversing direction, computing bounding boxes, and constructing parallel paths. These methods work on both PathBlock values and ProjectedPath values.

reverse() → PathBlock / ProjectedPath

Returns a new path with reversed direction of travel. The reversed path starts where the original ended and ends where the original started.

let p = @{ h 50 v 30 };
let r = p.reverse();
log(r.endPoint);             // Point(-50, -30) — reversed from original
M 100 100
r.draw()                     // draws the path in reverse

Smooth commands (S/T) are automatically converted to their explicit forms (C/Q) before reversal. Closed paths (ending with z) preserve closure.

let closed = @{ h 30 v 30 h -30 z };
let rev = closed.reverse();  // reversed, still ends with z

boundingBox(){ x, y, width, height }

Returns the axis-aligned bounding box of the path. Accounts for Bézier curve extrema and arc extrema — not just endpoints.

let p = @{ c 0 -40 50 -40 50 0 };
let bb = p.boundingBox();
log(bb.y);                    // negative — curve extends above endpoints
log(bb.width, bb.height);    // full extent of the curve

For a straight-line path the bounding box matches the endpoint coordinates:

let line = @{ h 100 };
let bb = line.boundingBox();
// bb = { x: 0, y: 0, width: 100, height: 0 }

intersects(geometry) → Boolean

AABB overlap test — returns true if this path's bounding box overlaps the argument's bounding box. Works on both PathBlock and ProjectedPath values.

Accepted arguments:

Argument type Comparison
PathBlock or ProjectedPath Bounding box vs bounding box
ProjectedText Path bbox vs text bbox
{x, y, width, height} object Path bbox vs rectangle
let a = @{ h 60 v 40 h -60 z };
let b = @{ h 40 v 30 h -40 z };

// Overlapping — both start at origin
let projA = a.project(0, 0);
let projB = b.project(10, 10);
log(projA.intersects(projB));        // true

// Non-overlapping
let projC = b.project(200, 200);
log(projA.intersects(projC));        // false

Testing against a rectangle object:

let shape = @{ h 50 v 50 h -50 z };
let proj = shape.project(10, 10);
log(proj.intersects({x: 0, y: 0, width: 100, height: 100}));    // true
log(proj.intersects({x: 200, y: 200, width: 10, height: 10}));  // false

Works on unprojected PathBlocks too (bounding box computed from relative coordinates):

let a = @{ h 60 v 40 h -60 z };
let b = @{ h 40 v 30 h -40 z };
log(a.intersects(b));                // true (both at origin)

intersectionPoints(geometry) → Array<Point>

Returns the intersection points between this path's bounding box edges and the geometry's line segments. Works on both PathBlock and ProjectedPath values.

Accepted arguments:

Argument type Returns
PathBlock or ProjectedPath Points where bbox edges cross path segments
ProjectedText Corners of the overlap rectangle (4 points), or empty array if no overlap
let box = @{ h 100 v 100 h -100 z };
let line = @{ m -10 50 h 120 };

let projBox = box.project(0, 0);
let projLine = line.project(0, 0);
let pts = projBox.intersectionPoints(projLine);
// pts contains the points where the line crosses the box's bounding box edges

Non-overlapping paths return an empty array:

let a = @{ h 50 v 50 h -50 z };
let b = @{ h 10 v 10 h -10 z };
let projA = a.project(0, 0);
let projB = b.project(200, 200);
let pts = projA.intersectionPoints(projB);
log(pts.length);                     // 0

offset(distance) → PathBlock / ProjectedPath

Creates a parallel path offset by distance units. Positive values offset to the left of the travel direction, negative to the right.

let p = @{ h 60 v 40 };
let outer = p.offset(5);     // 5 units left of travel
let inner = p.offset(-5);    // 5 units right of travel

Offset preserves curve types — cubic Béziers produce offset cubics, arcs produce offset arcs with adjusted radii. Segment joins use miter joins with a limit of 4× the offset distance.

let curve = @{ c 0 -40 50 -40 50 0 };
let parallel = curve.offset(3);
M 0 50
curve.draw()
M 0 50
parallel.draw()              // parallel curve 3 units to the left

mirror(angle) → PathBlock / ProjectedPath

Reflects the path across a line through the start point at the given angle. The angle uses standard language units (radians).

let p = @{ h 60 v 40 };
let m = p.mirror(0.5pi);       // reflect across vertical axis → goes left
M 100 100
m.draw()

Common angles:

  • mirror(0) — horizontal axis (y → -y)
  • mirror(0.5pi) — vertical axis (x → -x)
  • mirror(0.25pi) — diagonal (swaps x and y)

Mirror preserves path length and curve types. Arc commands have their sweep flag flipped (reflection reverses chirality) and their rotation parameter adjusted.

let curve = @{ c 0 -40 50 -40 50 0 };
let flipped = curve.mirror(0);
M 0 50
curve.draw()
M 0 50
flipped.draw()               // curve reflected below the axis

rotateAtVertexIndex(index, angle) → PathBlock / ProjectedPath

Rotates the path around the vertex at index (from the .vertices array) by angle radians. PathBlockValue results are normalized to (0, 0) start.

let p = @{ h 50 v 50 };
// p.vertices = [Point(0,0), Point(50,0), Point(50,50)]
let r = p.rotateAtVertexIndex(1, 0.5pi);  // rotate around corner
M 10 10
r.draw()

The index must be a non-negative integer within range. The rotation preserves path length and curve types. Arc commands have their rotation parameter adjusted.

// Create a radial pattern by rotating around the first vertex
let arm = @{ h 50 v 10 };
for (i in 0..5) {
  let angle = calc(i * 2 * 3.14159265358979 / 6);
  let r = arm.rotateAtVertexIndex(0, angle);
  M 100 100
  r.draw()
}

scale(sx, sy) → PathBlock / ProjectedPath

Scales the path from its start point. sx scales x-coordinates, sy scales y-coordinates.

let p = @{ h 50 v 30 };
let doubled = p.scale(2, 2);      // endPoint (100, 60)
let wide = p.scale(3, 1);         // endPoint (150, 30)
let flipped = p.scale(-1, 1);     // mirror across y-axis

Uniform scaling (sx == sy) preserves shape and scales arc radii proportionally. Non-uniform scaling (sx != sy) performs full ellipse eigendecomposition to compute new arc radii and rotation. Negative scale values flip the arc sweep flag (reflection reverses chirality).

let arc = @{ a 25 25 0 0 1 50 0 };
let wide = arc.scale(2, 1);       // stretched elliptical arc
let big = arc.scale(3, 3);        // uniform: radii tripled

subPath(startT, endT) → PathBlock

Extracts the geometric portion of a path between two arc-length fractions. Both startT and endT must be between 0 and 1. Always returns a PathBlock (normalized to (0, 0) origin), even when called on a ProjectedPath.

let p = @{ h 100 v 100 };
let first = p.subPath(0, 0.5);    // first half of the path
let second = p.subPath(0.5, 1);   // second half of the path
M 10 10
first.draw()
M 10 10
second.draw()                      // visually reconstructs the original

If startT > endT, the result is reversed (equivalent to .subPath(endT, startT).reverse()):

let p = @{ h 100 };
let rev = p.subPath(1, 0);        // full path, reversed direction

Use .get() on the ProjectedPath to find the absolute position, then .draw() the extracted PathBlock:

let p = @{ h 100 v 50 };
let proj = p.project(10, 20);
let start = proj.get(0.2);
let sub = proj.subPath(0.2, 0.8);  // PathBlock, normalized to (0,0)
M start.x start.y
sub.draw()                          // draws the middle 60% at the right position

Edge cases:

  • subPath(0, 1) returns approximately the original path
  • subPath(t, t) returns an empty PathBlock (not an error)
  • Works with all command types including curves and arcs
let curve = @{ c 0 -40 50 -40 50 0 };
let front = curve.subPath(0, 0.5);
M 0 50
curve.draw()
M 0 80
front.draw()                        // first half of the Bézier curve

Transforms on ProjectedPath

Projected paths return results in absolute coordinates:

let p = @{ h 100 };
let proj = p.project(10, 20);
let bb = proj.boundingBox();
// bb.x = 10, bb.y = 20 — absolute coordinates

let rev = proj.reverse();
log(rev.startPoint);         // Point(110, 20) — starts at original end

For mirror() on a ProjectedPath, the mirror line passes through the projection's start point. For rotateAtVertexIndex(), the rotation center is the absolute vertex position.

let p = @{ h 50 };
let proj = p.project(100, 100);
let m = proj.mirror(0.5pi);
// Mirrors across vertical line through (100, 100)
// startPoint stays at (100, 100), endPoint moves to (50, 100)

For scale() on a ProjectedPath, the scale center is the projection's start point:

let p = @{ h 50 v 30 };
let proj = p.project(10, 20);
let s = proj.scale(2, 2);
// startPoint stays at (10, 20), endPoint moves to (110, 80)

Concatenation (<<)

The << operator joins two PathBlocks end-to-end. The right path's relative commands continue from where the left path ends.

let a = @{ h 50 };
let b = @{ v 30 };
let c = calc(a << b);               // endPoint (50, 30)
M 10 10
c.draw()                             // draws "h 50 v 30"

Chaining works naturally since << is left-associative and the result is a PathBlock:

let a = @{ h 50 };
let b = @{ v 30 };
let d = calc(a << b << a);          // endPoint (100, 30)

Self-concatenation repeats the path:

let p = @{ h 50 };
let doubled = calc(p << p);         // endPoint (100, 0)

Concatenated paths support all PathBlock methods — draw, project, sampling, and transforms:

let combined = calc(a << b);
let rev = combined.reverse();
let mid = combined.get(0.5);

The << operator also works for style block merging. The operand types must match — mixing PathBlocks and style blocks throws an error.

Chamfers

Chamfers cut corners by replacing a vertex with a straight line segment. The incoming and outgoing edges are trimmed by the specified distance, and a line connects the two trim points.

chamfer(distance) → PathBlock / ProjectedPath

Chamfers all corner vertices with equal distance on both sides:

let box = @{ h 60 v 40 h -60 z };
let chamfered = box.chamfer(8);
M 10 10
chamfered.draw()

chamfer(d1, d2) → PathBlock / ProjectedPath

Asymmetric chamfer — d1 is the trim distance on the incoming edge, d2 on the outgoing edge:

let box = @{ h 60 v 40 h -60 z };
let asym = box.chamfer(5, 15);
M 10 10
asym.draw()

chamferAtVertex(index, distance) → PathBlock / ProjectedPath

Chamfers a single vertex by index (from the .vertices array):

let box = @{ h 60 v 40 h -60 z };
// box.vertices: Point(0,0), Point(60,0), Point(60,40), Point(0,40)
let oneCorner = box.chamferAtVertex(1, 10);
M 10 10
oneCorner.draw()

chamferAtVertex(index, d1, d2) → PathBlock / ProjectedPath

Asymmetric chamfer at a single vertex:

let box = @{ h 60 v 40 h -60 z };
let asym = box.chamferAtVertex(2, 5, 15);
M 10 10
asym.draw()

Edge cases

If the chamfer distance exceeds the available edge length, it is clamped to the edge length and a warning is logged. If the vertex index is out of range, an error is thrown.

Chamfers work with all command types — lines, curves, and arcs. For curves, the trim operation uses arc-length parameterization to find the exact split point.

Fillets

Fillets round corners by replacing a vertex with a circular arc. The incoming and outgoing edges are trimmed, and an arc tangent to both edges is inserted.

Scope: Line-line junctions only. At curve junctions, the fillet is skipped and a warning is logged.

fillet(radius) → PathBlock / ProjectedPath

Fillets all corner vertices with the given radius:

let box = @{ h 60 v 40 h -60 z };
let rounded = box.fillet(8);
M 10 10
rounded.draw()

filletAtVertex(index, radius) → PathBlock / ProjectedPath

Fillets a single vertex:

let box = @{ h 60 v 40 h -60 z };
let oneRound = box.filletAtVertex(1, 12);
M 10 10
oneRound.draw()

If the radius is too large for the available edge length, it is clamped and a warning is logged. If the vertex index is out of range, an error is thrown.

Elliptical Fillets

Elliptical fillets replace a corner with an elliptical arc instead of a circular one, allowing for more expressive corner shapes.

Scope: Line-line junctions only (same as circular fillets).

ellipticalFillet(rx, ry) → PathBlock / ProjectedPath

Fillets all corners with an elliptical arc of radii rx and ry:

let box = @{ h 60 v 40 h -60 z };
let eFilleted = box.ellipticalFillet(12, 6);
M 10 10
eFilleted.draw()

ellipticalFillet(rx, ry, rotation) → PathBlock / ProjectedPath

Elliptical fillet with a rotated ellipse (rotation in radians, default 0):

let box = @{ h 60 v 40 h -60 z };
let rotated = box.ellipticalFillet(12, 6, 0.3);
M 10 10
rotated.draw()

ellipticalFilletAtVertex(index, rx, ry) → PathBlock / ProjectedPath

Elliptical fillet at a single vertex:

let box = @{ h 60 v 40 h -60 z };
let one = box.ellipticalFilletAtVertex(1, 15, 8);
M 10 10
one.draw()

ellipticalFilletAtVertex(index, rx, ry, rotation) → PathBlock / ProjectedPath

Elliptical fillet at a single vertex with rotation:

let box = @{ h 60 v 40 h -60 z };
let one = box.ellipticalFilletAtVertex(2, 15, 8, 0.5);
M 10 10
one.draw()

Boolean Operations

Boolean operations combine two closed paths using set operations. Both paths must be closed (end with z or have coincident start and end points). The result preserves original curve types — no linearization.

See also: Standard Library path functions for creating shapes to use with boolean operations.

union(other) → PathBlock

Combines two paths into their union (outer boundary):

let a = @{ circle(30) };
let b = @{ circle(30) };
let combined = a.project(50, 50).union(b.project(70, 50));

difference(other) → PathBlock

Subtracts other from the path:

let plate = @{ circle(40) };
let hole = @{ circle(15) };
let result = plate.project(50, 50).difference(hole.project(50, 50));

intersection(other) → PathBlock

Returns only the overlapping region:

let a = @{ circle(30) };
let b = @{ circle(30) };
let overlap = a.project(50, 50).intersection(b.project(70, 50));

xor(other) → PathBlock

Returns the symmetric difference — everything in either path but not both:

let a = @{ circle(30) };
let b = @{ circle(30) };
let exclusive = a.project(50, 50).xor(b.project(70, 50));

Requirements and behavior

  • Both paths must be closed. Open paths throw an error.
  • The other argument can be a PathBlock or ProjectedPath.
  • Multi-component results produce multiple subpaths (M...z M...z).
  • All curve types (lines, cubics, quadratics, arcs) are preserved through the operation.
  • Results are always returned as PathBlock values (normalized to (0, 0) origin).

Font Integration

Font integration lets you convert text characters into PathBlock values — turning each glyph into vector paths you can draw, transform, sample, and combine with boolean operations.

@font Directive

The @font directive declares a font for use in the program. It must appear at the top level (not inside a function or block).

@font "Inter";
@font "Roboto Mono" 700;
@font "./fonts/CustomFont.ttf";

Syntax:

@font "family-or-path" [weight];
Part Required Description
Source Yes Font family name (e.g., "Inter") or file path (e.g., "./fonts/Custom.ttf")
Weight No Numeric weight 100–900 (default: 400)

Font loading by environment:

  • CLI: Loads from local file paths (relative to source file) or searches system font directories (/Library/Fonts, /System/Library/Fonts, ~/Library/Fonts on macOS; equivalent paths on Linux/Windows)
  • Playground: Fetches from Google Fonts CDN automatically

The directive is declarative metadata — the host environment loads fonts before compilation begins. If a font cannot be found, a warning is logged and compilation continues.

PathBlock.fromGlyph(text, styles)

Converts text into an array of PathBlock values — one per character. Each PathBlock contains the glyph's vector outline as relative path commands.

@font "Inter";

let glyphs = PathBlock.fromGlyph("A", ${ font-family: Inter; font-size: 48; });

M 50 100
glyphs[0].draw()

Arguments:

Argument Type Description
text string Characters to convert (each becomes a separate PathBlock)
styles style block Must contain font-family; optionally font-size (default 16) and font-weight (default 400)

Returns: Array of PathBlock values. Each element has all standard PathBlock properties and methods (draw(), project(), get(), tangent(), boundingBox(), scale(), boolean operations, etc.).

@font "Inter";
let styles = ${ font-family: Inter; font-size: 48; };
let glyphs = PathBlock.fromGlyph("Hi", styles);
log(glyphs.length);    // 2

advanceWidth

Each glyph PathBlock has an .advanceWidth property — the horizontal distance to advance the cursor after drawing the glyph. This enables manual text layout:

@font "Inter";
let styles = ${ font-family: Inter; font-size: 48; };
let glyphs = PathBlock.fromGlyph("Hello", styles);

let x = 10;
let y = 100;
for (g in glyphs) {
  M x y
  g.draw()
  let x = calc(x + g.advanceWidth);
}

Space characters return an empty PathBlock (no path commands) but still have a non-zero advanceWidth.

contours

Glyphs with multiple contours (e.g., "O" has an outer ring and inner hole) can be decomposed with the .contours property. This returns an array of PathBlock values, one per contour:

@font "Inter";
let styles = ${ font-family: Inter; font-size: 48; };
let glyphs = PathBlock.fromGlyph("O", styles);
let contours = glyphs[0].contours;
log(contours.length);              // 2 (outer + inner)

for (c in contours) {
  c.drawTo(100, 100)
}

Each contour is a closed PathBlock with all standard properties and methods.

Error cases

Condition Error message
Wrong number of arguments PathBlock.fromGlyph() expects 2 arguments (text, styles)
First argument not a string PathBlock.fromGlyph() first argument must be a string
Second argument not a style block PathBlock.fromGlyph() second argument must be a style block
Style block missing font-family PathBlock.fromGlyph() requires font-family in style block
No fonts loaded PathBlock.fromGlyph() requires font data. Use @font directive to load a font.
Font not in registry Font 'X' not found in font registry. Available fonts: [list]

TextBlock

TextBlock is a composition-first text primitive that lets you compose, measure, and position text before drawing it. This is essential for diagrams and schematics where labels must be positioned relative to geometry without overlapping.

TextBlock parallels PathBlock: both follow the pattern compose -> measure -> position -> draw.

Quick Overview

// Compose text at relative coordinates
let label = &{
  text(0, 14)`Title`
  text(0, 30)`Subtitle`
} << ${ font-size: 14; fill: #333; };

// Measure before placing
let bb = label.boundingBox();

// Project into absolute coordinates
let placed = label.project(50, 100);

// Draw to a TextLayer
define TextLayer('labels') ${}
layer('labels').apply {
  placed.draw();
}

Syntax

TextBlock uses the &{ } sigil:

let t = &{
  text(x, y)`content`
  text(x, y) {
    tspan()`first`
    tspan(0, 16)`second`
  }
};

Inside a text block you can use:

  • text() statements — the core text elements
  • let, for, if — control flow for dynamic content
  • User-defined functions — called as expressions

Not allowed inside text blocks:

  • Path commands (M, L, etc.)
  • Layer definitions or apply blocks
  • Nested text blocks

Types

TextBlockValue

Created by the &{ } expression. All coordinates are relative to origin (0, 0).

ProjectedTextValue

Created by .project(), .drawTo(), .polarProject(), or .translate(). Contains text elements with absolute coordinates and tracks the projection origin.

Methods

TextBlockValue

Method Returns Description
.project(x, y) ProjectedTextValue Offset all elements to absolute coordinates
.drawTo(x, y [, rotation]) ProjectedTextValue Emit to active TextLayer at position
.boundingBox() Object {x, y, width, height} Estimated bounding box
.polarProject(px, py, angle, distance, anchor) ProjectedTextValue Project along polar vector with anchor alignment
.toPathBlock() PathBlockValue Flatten glyph outlines into a single PathBlock (requires @font)
.toCodeSnippetBlock(name [, fontSize, padding]) LayerReference Generate a syntax-highlighted code snippet GroupLayer

ProjectedTextValue

Method Returns Description
.draw() ProjectedTextValue Emit to active TextLayer at projected position
.drawTo(x, y [, rotation]) ProjectedTextValue Re-project and emit at new position
.translate(dx, dy) ProjectedTextValue Return new value with shifted origin
.boundingBox() Object {x, y, width, height} Estimated bounding box
.paddedBoundingBox(blockPad, inlinePad) Object {x, y, width, height} Bbox expanded by padding
.anchor(BBoxAnchor) PointValue Point at named position on bbox
.intersects(geometry) Boolean AABB overlap test
.intersectionPoints(geometry) Array<PointValue> Intersection points between bbox and geometry

Properties

TextBlockValue

Property Type Description
.elementCount number Number of text elements
.styles StyleBlockValue Block-level styles

ProjectedTextValue

Property Type Description
.elementCount number Number of text elements
.styles StyleBlockValue Block-level styles
.origin PointValue Projection origin

Style Merging

Use the << operator to merge styles into a TextBlock:

let t = &{ text(0, 16)`Hello` } << ${ font-size: 24; fill: #333; };

This sets block-level styles that apply to all elements unless overridden by element-level styles.

BBoxAnchor Enum

The BBoxAnchor enum provides named positions on a bounding box:

BBoxAnchor.TopLeft      BBoxAnchor.Top      BBoxAnchor.TopRight
BBoxAnchor.Left         BBoxAnchor.Center   BBoxAnchor.Right
BBoxAnchor.BottomLeft   BBoxAnchor.Bottom   BBoxAnchor.BottomRight

Used with .anchor() and .polarProject().

Font Metrics

TextBlock uses built-in character width tables for bounding box estimation:

  • Sans-serif (default): per-character widths approximating Arial/Helvetica
  • Serif: per-character widths approximating Times New Roman
  • Monospace: uniform character width approximating Courier New

Set the font category via the font-family style property. Accuracy is ~85-90% for Latin text, sufficient for layout decisions.

Font metrics respect:

  • font-size (default 16)
  • font-family (category detection)
  • font-weight (bold applies ~6% width increase)
  • letter-spacing
  • tspan dx/dy offsets

Polar Projection

Place text along a polar vector with anchor alignment:

let label = &{ text(0, 14)`Node A` } << ${ font-size: 14; };

// Place label 80px from center at 45 degrees, anchored at center-left
let placed = label.polarProject(100, 100, 45deg, 80, BBoxAnchor.Left);

The anchor determines which point of the text's bounding box is placed at the target location. For example, BBoxAnchor.Left means the left-center of the text bbox lands on the polar target point.

Intersection Detection

Check if text bounding boxes overlap to avoid label collisions:

let label1 = (&{ text(0, 14)`First` } << ${ font-size: 14; }).project(50, 50);
let label2 = (&{ text(0, 14)`Second` } << ${ font-size: 14; }).project(55, 55);

if (label1.intersects(label2)) {
  // Labels overlap — adjust position
  label2 = label2.translate(0, 20);
}

.intersects() accepts:

  • Another ProjectedTextValue (AABB overlap test)
  • A ProjectedPathValue (bbox-edge vs path-segment intersection)
  • An object with {x, y, width, height} (AABB overlap test)

Text to Path Conversion

When you need text that renders identically without requiring fonts — or when you want to apply path transforms and boolean operations to text — .toPathBlock() converts glyph outlines into vector geometry. After conversion, the text is no longer a text element: it's path geometry that can be filled, stroked, scaled, mirrored, and combined with boolean operations like any other PathBlock.

This is different from PathBlock.fromGlyph(), which returns an array of per-character PathBlocks. .toPathBlock() returns a single PathBlock containing all glyphs from the entire TextBlock, already laid out according to element positions, tspan offsets, and letter-spacing.

Requirements:

  • Fonts must be loaded via @font directive or compile options
  • font-family must be set in the TextBlock's styles
  • Only available on TextBlockValue (not ProjectedTextValue)
@font "./fonts/Baumans-Regular.ttf";

let tb = &{
  text(0, 20)`Hello`
  text(0, 40)`World`
} << ${ font-family: Baumans-Regular; font-size: 24; };

let pb = tb.toPathBlock();

define PathLayer('text-as-path') ${ fill: #333; stroke: none; }
layer('text-as-path').apply {
  pb.drawTo(20, 20);
}

The resulting PathBlock is normalized to a (0, 0) origin, so .drawTo(x, y) places the text geometry at absolute coordinates (x, y). Space characters advance the cursor without generating outline commands.

Since the result is a standard PathBlock, you can chain any PathBlock operation:

// Scale the text geometry down to 60%
let small = pb.scale(0.6, 0.6);

// Mirror the text horizontally
let flipped = pb.mirror(0);

// Use text as a boolean punch — cut text out of a rectangle
let plate = @{ h 200 v 60 h -200 z }.project(10, 10);
let cutout = plate.difference(pb.project(20, 20));

Per-tspan style overrides (font-family, font-size) are respected, allowing mixed fonts within a single PathBlock output.

Code Snippet Blocks

For diagrams that need to show source code alongside visual output — tutorials, blog schematics, API documentation — .toCodeSnippetBlock() generates a self-contained code block as SVG layers with Pathogen-aware syntax highlighting.

.toCodeSnippetBlock(name [, fontSize, padding]) transforms a TextBlock containing code text into a styled GroupLayer.

Arguments:

  • name (string) — name for the GroupLayer
  • fontSize (number, optional) — code font size, default 10
  • padding (number, optional) — padding around code, default 12

Returns: LayerReference to a GroupLayer containing:

  • {name}-bg — PathLayer with dark background (#1e293b), border (#334155), and rounded corners
  • {name}-code — TextLayer with per-token syntax-highlighted tspan elements

The name must not collide with existing layer names (including the -bg and -code suffixed names).

let code = &{
  text(0, 0)`// Shape layer with styles
define PathLayer('main') \${ fill: #3b82f6; }

let shape = rect(0, 0, 80, 60);
layer('main').apply {
  shape.drawTo(50, 50);
}`
};

let snippet = code.toCodeSnippetBlock('my-snippet', 10, 12);
snippet << ${ translate-x: 400; translate-y: 100; };

Escaping ${ in Code Text

Template literals in Pathogen treat ${ as a string interpolation sequence. If your code text contains literal ${ (e.g., style blocks), escape the dollar sign with a backslash:

// ✗ This fails — ${ triggers interpolation
let code = &{ text(0,0)`let s = ${ fill: red; }` };

// ✓ Escape the dollar sign
let code = &{ text(0,0)`let s = \${ fill: red; }` };

@{ and &{ do not need escaping — they pass through template literals as plain text. Only ${ requires the \$ escape.

Syntax Highlighting Palette

Keywords and builtins share the same color (#c084fc) — both represent language-level constructs.

Token Color Examples
Keyword #c084fc (purple) let, for, if, define, fn
Builtin #c084fc (purple) PathLayer, Color, circle, log
Function #f59e0b (amber) any identifier followed by (
Number #f59e0b (amber) 42, 3.14, 45deg
String #22c55e (green) `hello`, "world"
Comment #64748b (slate) // comment
Operator #94a3b8 (gray) =, <<, +, -
Punctuation #64748b (slate) { } ( ) ; ,
Text #e2e8f0 (light) identifiers, whitespace

The code text is automatically normalized: common leading whitespace is removed (dedent), blank leading/trailing lines are trimmed, and tabs are converted to 2 spaces. Indentation within the code (for blocks, loops, conditionals) is preserved and rendered via x-coordinate offsets.

Examples

Label placement around a shape

define PathLayer('shape') ${ stroke: #333; fill: none; }
define TextLayer('labels') ${ font-size: 12; fill: #666; }

let shape = @{ l 80 0 l 0 60 l -80 0 z };

// Place labels at compass positions around the shape
let top = &{ text(0, 12)`Top` } << ${ font-size: 12; };
let right = &{ text(0, 12)`Right` } << ${ font-size: 12; };

layer('shape').apply { shape.drawTo(60, 70); }

layer('labels').apply {
  top.polarProject(100, 100, -90deg, 50, BBoxAnchor.Bottom).draw();
  right.polarProject(100, 100, 0, 60, BBoxAnchor.Left).draw();
}

Dynamic labels with collision avoidance

define TextLayer('labels') ${ font-size: 11; }

let points = [
  { x: 50, y: 50, name: "A" },
  { x: 55, y: 65, name: "B" },
  { x: 120, y: 50, name: "C" },
];

let placed = [];
layer('labels').apply {
  for (pt in points) {
    let label = &{ text(0, 11)`${pt.name}` } << ${ font-size: 11; };
    let proj = label.project(pt.x + 5, pt.y);

    // Check against all previously placed labels
    let ok = true;
    for (prev in placed) {
      if (proj.intersects(prev)) {
        ok = false;
      }
    }

    if (ok) {
      proj.draw();
      placed.push(proj);
    } else {
      // Try below instead
      let alt = label.project(pt.x + 5, calc(pt.y + 15));
      alt.draw();
      placed.push(alt);
    }
  }
}

Color Type

The Color type provides first-class color manipulation in OKLCH color space. Colors are resolved at compile time to concrete CSS values.

Color Literals

Hex color codes are first-class expressions — no quotes or Color() wrapper needed:

let c = #cc0000;                      // 6-digit hex → ColorValue
let c = #f00;                         // 3-digit shorthand
let c = #cc000080;                    // 8-digit with alpha
let c = #f008;                        // 4-digit with alpha

Color literals support method chaining via parentheses:

let lighter = (#cc0000).lighten(20%); // 20% → 0.2
let faded = (#0066ff).alpha(50%);     // 50% → 0.5

Color() accepts color literals as pass-through (no-op for backwards compatibility):

let c = Color(#cc0000);               // same as: let c = #cc0000;

CSS Color Function Literals

CSS color functions are first-class expressions with raw capture (content between parens is captured as-is):

let c = rgb(255, 0, 0);
let c = rgba(255, 0, 0, 0.5);
let c = hsl(0, 100%, 50%);           // % inside parens is literal
let c = hsla(0, 100%, 50%, 0.5);
let c = oklch(0.6 0.15 30);
let c = oklch(0.6 0.15 30 / 0.5);    // / for alpha is literal
let c = oklab(0.6 -0.1 0.15);
let c = hwb(0 0% 0%);
let c = lab(50 40 59.5);
let c = lch(50 64 30);

Method chaining works directly:

let lighter = rgb(255, 0, 0).lighten(20%);

Note: CSS color function names (rgb, hsl, oklch, etc.) are effectively reserved — they always produce color literals, even if a user-defined function of the same name exists.

Constructor

The Color() wrapper is still available for string-based construction and named colors:

let c = Color('#e63946');              // hex (3, 6, or 8 digit)
let c = Color('red');                  // named CSS color (all 148)
let c = Color('rgb(255, 0, 0)');       // rgb/rgba
let c = Color('hsl(0, 100%, 50%)');    // hsl/hsla
let c = Color('oklch(0.6 0.15 30)');   // oklch
let c = Color(0.6, 0.15, 30);         // direct OKLCH (L, C, H)
let c = Color(0.6, 0.15, 30, 0.5);    // OKLCH + alpha
let c = Color(#cc0000);               // pass-through (accepts ColorValue)

All input formats are converted to OKLCH internally for perceptually uniform manipulation.

Properties

Read-only properties for inspecting color values:

Property Type Description
.css string Hex if opaque, rgba() if transparent
.hex string #rrggbb (ignores alpha)
.oklch string oklch(L C H) or oklch(L C H / a)
.hsl string hsl(H, S%, L%)
.rgb string rgb(R, G, B)
.lightness number OKLCH lightness (0–1)
.chroma number OKLCH chroma (0–~0.4)
.hue number OKLCH hue (0–360)
.a number Alpha (0–1)
let c = Color('#e63946');
log(c.hex);        // #e63946
log(c.lightness);  // ~0.52
log(c.hue);        // ~27
log(c.a);          // 1

Methods

All methods return a new Color — they never mutate the original.

Lightness

let c = Color('#e63946');
let lighter = c.lighten(0.2);   // increase L by 0.2
let darker = c.darken(0.15);    // decrease L by 0.15
log(lighter.hex);  // lighter red
log(darker.hex);   // darker red

Saturation

let c = Color('#e63946');
let vivid = c.saturate(1.5);     // multiply chroma by 1.5
let muted = c.desaturate(0.5);   // multiply chroma by 0.5

Alpha

let c = Color('#e63946');
let semi = c.alpha(0.5);
log(semi.css);  // rgba(230, 57, 70, 0.5)

Hue

let c = Color('#e63946');
let shifted = c.hueShift(180);   // shift hue by 180°
let comp = c.complement();       // shorthand for hueShift(180)

Mixing

Mix two colors in OKLCH space:

let a = Color('#e63946');
let b = Color('#457b9d');
let mid = a.mix(b, 0.5);         // 50/50 mix
let mostly_a = a.mix(b, 0.2);    // 80% a, 20% b

Method Chaining

Methods return new Colors, so they chain naturally:

let c = Color('#e63946')
  .lighten(0.1)
  .desaturate(0.8)
  .alpha(0.9);

Color Harmonies

Generate sets of harmonious colors based on color theory. All harmony methods return an array of Colors, preserving lightness, chroma, and alpha.

.analogous(angle?)

Returns 3 colors: [hue - angle, self, hue + angle]. Default angle: 30.

let c = Color('#e63946');
let colors = c.analogous();       // 3 colors at -30°, 0°, +30°
let wide = c.analogous(45);       // wider spread at ±45°

.triadic()

Returns 3 colors evenly spaced at 120° intervals: [self, hue + 120, hue + 240].

let c = Color('#e63946');
let colors = c.triadic();

.tetradic()

Returns 4 colors evenly spaced at 90° intervals: [self, hue + 90, hue + 180, hue + 270].

let c = Color('#e63946');
let colors = c.tetradic();

.splitComplementary(angle?)

Returns 3 colors: [self, hue + 180 - angle, hue + 180 + angle]. Default angle: 30.

let c = Color('#e63946');
let colors = c.splitComplementary();     // flanks of complement at ±30°
let narrow = c.splitComplementary(15);   // tighter split

Using Harmonies

Harmony methods return arrays, so use for-each to iterate:

let c = Color('#e63946');
for ([color, i] in c.triadic()) {
  define PathLayer(`p${i}`) ${ fill: color; stroke: none; }
  layer(`p${i}`).apply { circle(calc(50 + i * 60), 100, 25) }
}

Complete Example

A full color swatch showcase demonstrating base methods, harmonies, palettes, and derived colors across multiple tiers. Uses CSSVar-backed Colors so that changing --base-color or --accent-color in the playground's CSS var panel reactively updates every swatch. Connecting lines show how colors flow from a single base color through transformations.

Canvas: 600 × 700 viewBox with four sections flowing top-to-bottom.

// ═══════════════════════════════════════════════════════════
// Color Swatch Showcase — full demo of Color manipulation,
// harmonies, palettes, and derived colors
// ═══════════════════════════════════════════════════════════

let base = Color(CSSVar('--base-color', '#e63946'));
let accent = Color(CSSVar('--accent-color', '#457b9d'));

// ── Tier 0: Base Methods ──────────────────────────────────

let lighter   = base.lighten(0.15);
let darker    = base.darken(0.15);
let vivid     = base.saturate(1.4);
let muted     = base.desaturate(0.5);
let shifted   = base.hueShift(60);
let comp      = base.complement();
let semi      = base.alpha(0.6);
let mixed     = base.mix(accent, 0.5);

// ── Tier 1: Harmonies ────────────────────────────────────

let analog  = base.analogous();
let triad   = base.triadic();
let tetrad  = base.tetradic();
let split   = base.splitComplementary();

// ── Tier 1b: Palettes ────────────────────────────────────

let ramp   = Color.palette(base, 5);
let interp = Color.palette(base, accent, 5);

// ── Tier 2: Derived Colors ───────────────────────────────

let tri1       = triad[1];
let tri1Light  = tri1.lighten(0.15);
let tri1Dark   = tri1.darken(0.15);
let tri1Vivid  = tri1.saturate(1.4);

let rampMid      = ramp[2];
let rampShifted  = rampMid.hueShift(60);
let rampComp     = rampMid.complement();
let rampAlpha    = rampMid.alpha(0.5);

// ═══════════════════════════════════════════════════════════
// Layers
// ═══════════════════════════════════════════════════════════

define PathLayer('connectors') ${
  stroke: #999;
  stroke-width: 1;
  fill: none;
}

define TextLayer('section-labels') ${
  font-family: system-ui, sans-serif;
  font-size: 13;
  font-weight: bold;
  fill: #555;
}

define TextLayer('labels') ${
  font-family: system-ui, sans-serif;
  font-size: 9;
  fill: #777;
  text-anchor: middle;
}

// ── Swatch sizing ────────────────────────────────────────

let sx = 64;
let sp = 96;
let sw = 36;
let sh = 36;
let sr = 6;

// ═══════════════════════════════════════════════════════════
// Tier 0: Base Method Swatches
// ═══════════════════════════════════════════════════════════

// Row 1: base, lighten, darken, saturate, desaturate
let row1 = [base, lighter, darker, vivid, muted];
let row1names = ['base', 'lighten', 'darken', 'saturate', 'desat'];
for ([color, i] in row1) {
  let x = calc(sx + i * sp);
  define PathLayer(`t0r1_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`t0r1_${i}`).apply { roundRect(calc(x - sw / 2), calc(50 - sh / 2), sw, sh, sr) }
}

// Row 2: hueShift, complement, alpha, mix, accent
let row2 = [shifted, comp, semi, mixed, accent];
let row2names = ['hueShift', 'compl.', 'alpha', 'mix', 'accent'];
for ([color, i] in row2) {
  let x = calc(sx + i * sp);
  define PathLayer(`t0r2_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`t0r2_${i}`).apply { roundRect(calc(x - sw / 2), calc(115 - sh / 2), sw, sh, sr) }
}

// Section header
layer('section-labels').apply {
  text(10, 20)`Base Methods`
}

// Row 1 labels
layer('labels').apply {
  for ([name, i] in row1names) {
    text(calc(sx + i * sp), calc(50 + sh / 2 + 12))`${name}`
  }
}

// Row 2 labels
layer('labels').apply {
  for ([name, i] in row2names) {
    text(calc(sx + i * sp), calc(115 + sh / 2 + 12))`${name}`
  }
}

// Section divider
layer('connectors').apply {
  M 10 170
  L 590 170
}

// ═══════════════════════════════════════════════════════════
// Tier 1: Harmonies
// ═══════════════════════════════════════════════════════════

layer('section-labels').apply {
  text(10, 195)`Harmonies`
}

let hsx = 160;
let hsp = 55;
let hsw = 30;
let hsh = 30;
let hsr = 5;

// Row 3: analogous
for ([color, i] in analog) {
  let x = calc(hsx + i * hsp);
  define PathLayer(`analog_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`analog_${i}`).apply {
    roundRect(calc(x - hsw / 2), calc(220 - hsh / 2), hsw, hsh, hsr)
  }
}

// Row 4: triadic
for ([color, i] in triad) {
  let x = calc(hsx + i * hsp);
  define PathLayer(`triad_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`triad_${i}`).apply {
    roundRect(calc(x - hsw / 2), calc(275 - hsh / 2), hsw, hsh, hsr)
  }
}

// Row 5: tetradic
for ([color, i] in tetrad) {
  let x = calc(hsx + i * hsp);
  define PathLayer(`tetrad_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`tetrad_${i}`).apply {
    roundRect(calc(x - hsw / 2), calc(330 - hsh / 2), hsw, hsh, hsr)
  }
}

// Row 6: splitComplementary
for ([color, i] in split) {
  let x = calc(hsx + i * hsp);
  define PathLayer(`split_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`split_${i}`).apply {
    roundRect(calc(x - hsw / 2), calc(385 - hsh / 2), hsw, hsh, hsr)
  }
}

// Harmony row labels
define TextLayer('hlabels') ${
  font-family: system-ui, sans-serif;
  font-size: 10;
  fill: #888;
}
layer('hlabels').apply {
  text(30, 224)`analogous`
  text(30, 279)`triadic`
  text(30, 334)`tetradic`
  text(30, 389)`splitComp.`
}

// Section divider
layer('connectors').apply {
  M 10 420
  L 590 420
}

// ═══════════════════════════════════════════════════════════
// Tier 1b: Palettes
// ═══════════════════════════════════════════════════════════

layer('section-labels').apply {
  text(10, 445)`Palettes`
}

// Row 7: lightness ramp
for ([color, i] in ramp) {
  let x = calc(hsx + i * hsp);
  define PathLayer(`ramp_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`ramp_${i}`).apply {
    roundRect(calc(x - hsw / 2), calc(470 - hsh / 2), hsw, hsh, hsr)
  }
}

// Row 8: interpolation
for ([color, i] in interp) {
  let x = calc(hsx + i * hsp);
  define PathLayer(`interp_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`interp_${i}`).apply {
    roundRect(calc(x - hsw / 2), calc(525 - hsh / 2), hsw, hsh, hsr)
  }
}

// Palette row labels
layer('hlabels').apply {
  text(30, 474)`palette(c,5)`
  text(30, 529)`palette(a,b,5)`
}

// Section divider
layer('connectors').apply {
  M 10 560
  L 590 560
}

// ═══════════════════════════════════════════════════════════
// Tier 2: Derived Colors
// ═══════════════════════════════════════════════════════════

layer('section-labels').apply {
  text(10, 583)`Derived Colors`
}

let dsx = 260;
let dsp = 70;

// Row 9: triadic[1] → lighten, darken, saturate
define PathLayer('tri1_parent') ${ fill: tri1; stroke: #666; stroke-width: 1; }
layer('tri1_parent').apply {
  roundRect(calc(hsx - hsw / 2), calc(605 - hsh / 2), hsw, hsh, hsr)
}

let derived1 = [tri1Light, tri1Dark, tri1Vivid];
let derived1names = ['lighten', 'darken', 'saturate'];
for ([color, i] in derived1) {
  let x = calc(dsx + i * dsp);
  define PathLayer(`d1_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`d1_${i}`).apply {
    roundRect(calc(x - hsw / 2), calc(605 - hsh / 2), hsw, hsh, hsr)
  }
}

// Row 10: ramp[2] → hueShift, complement, alpha
define PathLayer('ramp2_parent') ${ fill: rampMid; stroke: #666; stroke-width: 1; }
layer('ramp2_parent').apply {
  roundRect(calc(hsx - hsw / 2), calc(660 - hsh / 2), hsw, hsh, hsr)
}

let derived2 = [rampShifted, rampComp, rampAlpha];
let derived2names = ['hueShift', 'compl.', 'alpha'];
for ([color, i] in derived2) {
  let x = calc(dsx + i * dsp);
  define PathLayer(`d2_${i}`) ${ fill: color; stroke: #ccc; stroke-width: 0.5; }
  layer(`d2_${i}`).apply {
    roundRect(calc(x - hsw / 2), calc(660 - hsh / 2), hsw, hsh, hsr)
  }
}

// Derived row labels
layer('hlabels').apply {
  text(30, 609)`triad[1] →`
  text(30, 664)`ramp[2] →`
}

// Derived swatch labels
layer('labels').apply {
  for ([name, i] in derived1names) {
    text(calc(dsx + i * dsp), calc(605 + hsh / 2 + 12))`${name}`
  }
  for ([name, i] in derived2names) {
    text(calc(dsx + i * dsp), calc(660 + hsh / 2 + 12))`${name}`
  }
}

// ── Connecting lines (reactivity chain) ──────────────────

layer('connectors').apply {
  // Vertical from base swatch down to tier 1 divider
  M sx calc(50 + sh / 2)
  L sx 170

  // Connector: triadic[1] down to derived row 9
  M calc(hsx + 1 * hsp) calc(275 + hsh / 2)
  L calc(hsx + 1 * hsp) calc(605 - hsh / 2 - 5)
  L hsx calc(605 - hsh / 2 - 5)
  L hsx calc(605 - hsh / 2)

  // Arrow: parent → derived in row 9
  M calc(hsx + hsw / 2) 605
  L calc(dsx - hsw / 2) 605

  // Connector: ramp[2] down to derived row 10
  M calc(hsx + 2 * hsp) calc(470 + hsh / 2)
  L calc(hsx + 2 * hsp) calc(660 - hsh / 2 - 5)
  L hsx calc(660 - hsh / 2 - 5)
  L hsx calc(660 - hsh / 2)

  // Arrow: parent → derived in row 10
  M calc(hsx + hsw / 2) 660
  L calc(dsx - hsw / 2) 660
}

Compile with: svg-path-extended --output-svg-file=swatches.svg --viewBox="0 0 600 700" --width="600" --height="700"

Static Methods

Color.mix(c1, c2, ratio)

Mix two colors at a given ratio (0 = all c1, 1 = all c2):

let a = Color('#e63946');
let b = Color('#457b9d');
let mid = Color.mix(a, b, 0.5);

Color.palette(color, n)

Generate a lightness ramp of n colors from dark (L=0.15) to light (L=0.95), preserving hue and chroma:

let c = Color('#e63946');
let shades = Color.palette(c, 5);   // 5 shades from dark to light

Color.palette(c1, c2, n)

Generate n evenly interpolated colors between two colors:

let a = Color('#e63946');
let b = Color('#457b9d');
let gradient = Color.palette(a, b, 7);   // 7-step gradient

n must be an integer >= 2.

Color.lightDark(light, dark)

Create a theme-aware color that uses CSS light-dark() in style output:

let fg = Color.lightDark(Color('#333'), Color('#eee'));
// Style output: light-dark(#333333, #eeeeee)

Works with CSSVar-backed colors for full customizability:

let fg = Color.lightDark(
  Color(CSSVar('--fg-light', '#333')),
  Color(CSSVar('--fg-dark', '#eee'))
);
// Style output: light-dark(var(--fg-light, #333), var(--fg-dark, #eee))

Both arguments must be Colors. At compile time, .hex, .lightness, and other properties resolve to the light variant. Method calls (.lighten(), .hueShift(), etc.) operate on the light variant and lose the light-dark semantics.

@property Declarations

When you create a Color(CSSVar('--name', fallback)), the compiler automatically collects a CSS @property declaration for that custom property. This enables browsers to interpolate the property in transitions and animations.

The collected declarations appear in CompileResult.cssProperties and are emitted as a <style> block in CLI SVG output:

<svg ...>
  <style>
    @property --base-color {
      syntax: "<color>";
      inherits: true;
      initial-value: #e63946;
    }
  </style>
  ...
</svg>

Only Color-typed CSSVars produce @property declarations — plain CSSVar('--width', 2) does not. When the same variable name appears multiple times, the first occurrence wins.

Style Block Auto-Conversion

Colors auto-convert to CSS strings when used in style blocks:

let primary = Color('#e63946');
let light = primary.lighten(0.2);

layer PathLayer('main') ${
  stroke: primary;
  fill: light;
}

This outputs stroke="#e63946" and fill as the lightened hex value — no .css property needed.

Template Literals

Colors display as Color(#hex) in template literals and log():

let c = Color('#e63946');
log(c);              // Color(#e63946)
log(`color: ${c}`);  // color: Color(#e63946)

Roundtrip Fidelity

Standard CSS colors roundtrip exactly:

let c = Color('#ff0000');
log(c.hex);  // #ff0000

Named Colors

All 148 CSS named colors are supported:

let c = Color('coral');
let c = Color('dodgerblue');
let c = Color('mediumseagreen');

Named color lookup is case-insensitive.

Gradients

Gradients define SVG paint servers (<linearGradient> and <radialGradient>) that can be used as fill or stroke values on layers.

LinearGradient

Create a linear gradient with an ID and coordinates defining the gradient axis:

let fade = LinearGradient('fade', 0, 0, 1, 1) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(0.5, Color('#f4a261'));
  g.stop(1, Color('#2a9d8f'));
};

Constructor signature: LinearGradient(id, x1, y1, x2, y2) — coordinates are in objectBoundingBox units by default (0–1 range).

RadialGradient

Create a radial gradient with an ID, center point, and radius:

let glow = RadialGradient('glow', 0.5, 0.5, 0.5) {|g|
  g.stop(0, Color('#ffffff'));
  g.stop(1, Color('#000000').alpha(0));
};

Constructor signature: RadialGradient(id, cx, cy, r) — optional focal point: RadialGradient(id, cx, cy, r, fx, fy).

Trailing Block Syntax

Both constructors accept a trailing block {|g| ... } where g is bound to the newly created gradient. Use g.stop(offset, color) inside the block to add color stops:

  • offset — a number from 0 to 1 (position along the gradient axis)
  • color — any Color value (Color('#hex'), Color('named'), OKLCH constructor, etc.)

The block is optional — you can create an empty gradient and add stops later or use .inherit() to derive from another gradient.

let empty = LinearGradient('empty', 0, 0, 1, 0);

Using Gradients in Styles

Reference a gradient in fill or stroke style properties. The compiler automatically wraps the gradient ID as url(#id):

let g = LinearGradient('sunset', 0, 0, 1, 0) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(1, Color('#2a9d8f'));
};

define PathLayer('bg') ${ fill: g; stroke: none; }

layer('bg').apply {
  M 0 0 L 200 0 L 200 200 L 0 200 Z
}

This produces fill="url(#sunset)" on the output <path> element, with a <linearGradient id="sunset"> in <defs>.

Gradient Attributes

Set optional attributes via property assignment after creation:

let g = LinearGradient('repeat-fade', 0, 0, 0.25, 0) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(1, Color('#2a9d8f'));
};

g.spreadMethod = 'repeat';
g.gradientUnits = 'userSpaceOnUse';
g.gradientTransform = 'rotate(45)';
Property Values Default
spreadMethod 'pad', 'reflect', 'repeat' 'pad'
gradientUnits 'objectBoundingBox', 'userSpaceOnUse' 'objectBoundingBox'
gradientTransform SVG transform string none
interpolation 'srgb', 'oklch', 'linearRGB' 'srgb'
steps Number of intermediate stops per unit offset 10

Color Interpolation

Control how colors transition between stops using the interpolation property.

OKLCh Interpolation

Set interpolation = 'oklch' for perceptually uniform transitions. The compiler expands stops at compile time using OKLCh color mixing, avoiding the muddy midpoints common with sRGB interpolation:

let smooth = LinearGradient('smooth', 0, 0, 1, 0) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(1, Color('#2a9d8f'));
};
smooth.interpolation = 'oklch';
smooth.steps = 12;  // 12 intermediate stops per unit offset (default: 10)

The steps property controls the density of generated intermediate stops. Higher values produce smoother transitions but increase SVG output size. The compiler:

  1. Iterates adjacent stop pairs
  2. Generates ceil(steps * offsetSpan) - 1 intermediate stops between each pair
  3. Uses mixColors() for shortest-arc hue interpolation in OKLCh space
  4. Always preserves the original stops at their exact offsets

linearRGB Interpolation

Set interpolation = 'linearRGB' for physically linear color transitions. This uses the native SVG color-interpolation attribute — no stop expansion is needed:

let physical = LinearGradient('physical', 0, 0, 1, 0) {|g|
  g.stop(0, Color('#ff0000'));
  g.stop(1, Color('#0000ff'));
};
physical.interpolation = 'linearRGB';

This emits color-interpolation="linearRGB" on the gradient element. The browser handles the interpolation natively.

Default (sRGB)

When interpolation is not set (or set to 'srgb'), the browser's default sRGB interpolation is used. No additional attributes or stop expansion occur.

Reactive Gradient Stops

Use Color(CSSVar(...)) in gradient stops to create live-updating gradients that respond to CSS custom property changes:

let accent = Color(CSSVar('--accent', '#e63946'));
let reactive = LinearGradient('reactive', 0, 0, 1, 0) {|g|
  g.stop(0, accent);            // → stop-color="var(--accent, #e63946)"
  g.stop(1, Color('#2a9d8f'));
};

The compiler preserves the var() reference in the stop-color attribute, allowing the gradient to update when the custom property changes at runtime.

CSSVar stops are skipped during OKLCh expansion — since their actual color is determined at runtime, the compiler cannot interpolate them at compile time. Non-CSSVar stops adjacent to CSSVar stops will not have intermediate stops generated between them.

Pattern Paint Server

Create a tiling pattern with an ID, position, and tile dimensions:

let dot = @{ circle(10, 10, 3) };
let dots = Pattern('dots', 0, 0, 20, 20) {|p|
  p.append(dot, ${ fill: Color('#e63946'); });
};
dots.patternUnits = 'userSpaceOnUse';

Constructor signature: Pattern(id, x, y, width, height) — defines the tile origin and size.

Pattern Methods

Use .append(pathBlock, styles?) inside the trailing block to add path elements to the pattern. This works the same way as Mask.append():

  • pathBlock — a PathBlock (@{ ... }) or ProjectedPath
  • styles — optional style block for the path element
let line = @{ m 0 0 l 20 20 };
let hatch = Pattern('hatch', 0, 0, 20, 20) {|p|
  p.append(line, ${ stroke: Color('#999'); stroke-width: 1; });
};

Pattern Properties

Property Values Default
patternUnits 'objectBoundingBox', 'userSpaceOnUse' 'objectBoundingBox'
patternTransform SVG transform string none
patternContentUnits 'objectBoundingBox', 'userSpaceOnUse' 'userSpaceOnUse'

Using Patterns in Styles

Reference a pattern in fill or stroke style properties, just like gradients:

define PathLayer('bg') ${ fill: dots; stroke: none; }
layer('bg').apply { M 0 0 L 200 0 L 200 200 L 0 200 Z }

This produces fill="url(#dots)" on the output <path> element.

Pattern SVG Output

<defs>
  <pattern id="dots" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
    <path d="M 7 10 A 3 3 0 0 1 13 10 A 3 3 0 0 1 7 10" fill="#e63946"/>
  </pattern>
</defs>

Conic Gradient

Create a conic (angular) gradient with an ID and center point:

let wheel = ConicGradient('wheel', 100, 100) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(0.33, Color('#2a9d8f'));
  g.stop(0.66, Color('#264653'));
  g.stop(1, Color('#e63946'));
};

Constructor signature: ConicGradient(id, cx, cy) — center coordinates in user space.

Conic gradients use the same .stop(offset, color) method as linear and radial gradients. Stops map to the angular sweep: offset 0 is the start angle, offset 1 is the end angle.

Conic Gradient Properties

Property Values Default
from Start angle (requires unit: deg, rad, pi) 0rad (3 o'clock)
to End angle (requires unit) from + 2pi (full revolution)
direction 'cw', 'ccw' 'cw'
spread 'clamp', 'repeat', 'transparent' 'clamp'
innerRadius Number (pixels) 0
innerFill 'transparent', 'transparent-blend', 'center', or Color(...) 'transparent'
interpolation 'srgb', 'oklch', 'linearRGB' 'srgb'
steps Intermediate stop density 10

Angle Units Required

The from and to properties require an angle unit suffix on literal numbers:

gauge.from = 135deg;     // degrees → converted to radians
gauge.to = 2.356rad;     // radians (used as-is)
gauge.from = 0.75pi;     // multiples of π

gauge.from = 135;        // ERROR: requires angle unit. Use 135deg

Computed expressions and function results are accepted without unit checks (they are assumed to already be in radians):

gauge.from = rad(135);   // OK — rad() returns radians

Partial Sweep

Set from and to for arcs less than (or greater than) a full revolution:

// Gauge: 270° arc with gap at bottom
let gauge = ConicGradient('gauge', 100, 100) {|g|
  g.stop(0, Color('#2a9d8f'));
  g.stop(0.5, Color('#e9c46a'));
  g.stop(1, Color('#e63946'));
};
gauge.from = 135deg;
gauge.to = 405deg;

Direction

direction controls which way colors sweep within the arc:

  • 'cw' (default) — colors flow clockwise from from to to
  • 'ccw' — colors flow counter-clockwise (stop offsets are reversed)
let reversed = ConicGradient('rev', 100, 100) {|g|
  g.stop(0, Color('#000'));
  g.stop(1, Color('#fff'));
};
reversed.direction = 'ccw';

Spread Modes

spread controls what happens outside the [from, to] arc for partial sweeps:

Spread Effect
'clamp' Edge colors extend to fill remaining area
'repeat' Pattern tiles to fill remaining area
'transparent' Outside-arc area is empty (no wedges emitted)

Inner Radius

Set innerRadius to create a smooth center plateau — the area within innerRadius pixels of the center blends smoothly into the angular sweep:

gauge.innerRadius = 30;

By default, the center area is transparent with a hard edge (a "donut hole"). Use innerFill to control what fills inside the inner radius:

Value Effect
'transparent' Hard cutoff — empty center (default)
'transparent-blend' Smooth blend from transparent at center to gradient at edge
'center' Smooth blend from first stop color at center to gradient at edge
Color(...) Smooth blend from custom color at center to gradient at edge
gauge.innerFill = 'transparent';        // hard donut hole (default)
gauge.innerFill = 'transparent-blend';  // soft transparent fade
gauge.innerFill = 'center';             // first-stop color, blends outward
gauge.innerFill = Color('#1a1a2e');     // custom color, blends outward

This is useful for donut-style gauges and ring charts. Inner radius rendering requires WebGPU, which is only available in the playground. The CLI wedge-path renderer ignores innerRadius and emits a warning when it is set.

// Ring gauge with transparent center and partial sweep
let ring = ConicGradient('ring', 100, 100) {|g|
  g.stop(0, Color('#2a9d8f'));
  g.stop(0.5, Color('#e9c46a'));
  g.stop(1, Color('#e63946'));
};
ring.from = 135deg;
ring.to = 405deg;
ring.innerRadius = 30;
ring.innerFill = 'transparent';  // donut hole

Rendering

Since SVG has no native conic gradient element, the output depends on the consumer:

  • CLI (--output-svg-file): Wedge-path SVG approximation wrapped in <pattern>. Each ~1° slice is an individual <path> element with an interpolated fill color.
  • Playground: Canvas 2D createConicGradient() → rendered to a PNG image → injected as <pattern><image/></pattern> for higher quality.

Both approaches are referenced via url(#id) in fill/stroke, identical to native gradients.

OKLCh Interpolation

Conic gradients support OKLCh interpolation via the shared interpolation and steps properties:

let smooth = ConicGradient('smooth', 100, 100) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(1, Color('#2a9d8f'));
};
smooth.interpolation = 'oklch';
smooth.steps = 15;

Conic Gradient CSS Variable Limitation

Conic gradients are rasterized at compile time (Canvas 2D in the playground, wedge-path approximation in the CLI). This means Color(CSSVar(...)) stops in conic gradients are baked out — the fallback color is extracted and used directly in the rasterized output.

Unlike linear and radial gradients, which use native SVG elements with live var() references, conic gradients will not update when CSS custom properties change at runtime.

Unfortunately, live-updating CSS variable colors is only available in the playground at this time. The compiler emits a warning when conic gradients contain CSSVar stops.

Conic Gradient Inheritance

Use .inherit(newId) to create child conic gradients. All conic-specific properties (from, to, direction, spread, innerRadius, innerFill) propagate to the child:

let child = wheel.inherit('child-wheel');
child.from = 90deg;

Gradient Inheritance

Create a new gradient that inherits stops and attributes from an existing one using .inherit(newId):

let base = LinearGradient('base', 0, 0, 1, 0) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(0.5, Color('#f4a261'));
  g.stop(1, Color('#2a9d8f'));
};

let rotated = base.inherit('rotated');
rotated.gradientTransform = 'rotate(90, 0.5, 0.5)';

The inherited gradient uses SVG's href attribute to reference the parent. It inherits all stops and attributes from the parent, and you can override specific attributes on the child. Inherited gradients with no stops of their own produce self-closing elements.

Property Access

Expression Returns
gradient.id The gradient's string ID
gradient.spreadMethod Current spreadMethod or undefined
gradient.gradientUnits Current gradientUnits or undefined
gradient.gradientTransform Current gradientTransform or undefined
gradient.interpolation Current interpolation mode or null
gradient.steps Current steps value or null
gradient.from Conic: start angle in radians (default 0)
gradient.to Conic: end angle in radians (default )
gradient.direction Conic: 'cw' or 'ccw' (default 'cw')
gradient.spread Conic: spread mode (default 'clamp')
gradient.innerRadius Conic: center plateau radius in pixels (default 0)
gradient.innerFill Conic: inner fill mode — 'transparent', 'transparent-blend', 'center', or Color value
pattern.id The pattern's string ID
pattern.patternUnits Current patternUnits or null
pattern.patternTransform Current patternTransform or null
pattern.patternContentUnits Current patternContentUnits or null

Dynamic Stop Generation

Use loops and expressions inside the trailing block for programmatic stops:

let ramp = LinearGradient('ramp', 0, 0, 1, 0) {|g|
  let colors = ['#e63946', '#f4a261', '#2a9d8f', '#264653', '#e9c46a'];
  for ([color, i] in colors) {
    g.stop(calc(i / 4), Color(color));
  }
};

Any statement valid in the language can appear inside the block — for loops, if statements, let bindings, function calls, etc.

SVG Output

The compiler produces gradient definitions in the <defs> section:

<defs>
  <linearGradient id="fade" x1="0" y1="0" x2="1" y2="1">
    <stop offset="0" stop-color="rgb(89.56% 22.41% 27.51%)"/>
    <stop offset="0.5" stop-color="rgb(95.69% 63.53% 38.04%)"/>
    <stop offset="1" stop-color="rgb(16.47% 61.57% 56.08%)"/>
  </linearGradient>
</defs>

Radial gradients use the <radialGradient> tag with cx, cy, r (and optionally fx, fy) attributes.

Inherited gradients use href:

<linearGradient id="rotated" href="#base" gradientTransform="rotate(90, 0.5, 0.5)"/>

Output Format

When using the JavaScript API, gradients appear in result.gradients:

const result = compile(`
  let g = LinearGradient('fade', 0, 0, 1, 1) {|g|
    g.stop(0, Color('#e63946'));
    g.stop(1, Color('#2a9d8f'));
  };
`);

// result.gradients:
// [
//   {
//     id: 'fade',
//     type: 'linear',
//     attrs: { x1: '0', y1: '0', x2: '1', y2: '1' },
//     stops: [
//       { offset: 0, color: 'rgb(89.56% 22.41% 27.51%)' },
//       { offset: 1, color: 'rgb(16.47% 61.57% 56.08%)' }
//     ]
//   }
// ]

Error Handling

Error Cause
Duplicate defs ID 'x' ID conflicts with another gradient, mask, clipPath, or pattern
LinearGradient() expects 5 arguments Wrong argument count
RadialGradient() expects 4-6 arguments Wrong argument count
ConicGradient() expects 3 arguments Wrong argument count
Pattern() expects 5 arguments Wrong argument count
First argument must be a string Non-string ID
stop() offset must be a number Non-numeric stop offset
stop() color must be a Color value Non-Color stop color
requires an angle unit Bare number on conic from/to (use 135deg)
direction must be 'cw' or 'ccw' Invalid conic direction
spread must be 'clamp', 'repeat', or 'transparent' Invalid conic spread
innerRadius must be a number Non-numeric innerRadius
innerRadius must be >= 0 Negative innerRadius
innerFill must be 'transparent', 'transparent-blend', 'center', or a Color value Invalid innerFill

Full Example

// Define a gradient palette
let warm = LinearGradient('warm', 0, 0, 0, 1) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(0.5, Color('#f4a261'));
  g.stop(1, Color('#e9c46a'));
};

let cool = RadialGradient('cool', 0.5, 0.5, 0.5) {|g|
  g.stop(0, Color('#2a9d8f'));
  g.stop(1, Color('#264653'));
};

// Use in layer styles
define PathLayer('bg') ${ fill: warm; stroke: none; }
define PathLayer('circle') ${ fill: cool; stroke: none; }

layer('bg').apply {
  M 0 0 L 200 0 L 200 200 L 0 200 Z
}

layer('circle').apply {
  circle(100, 100, 60)
}

Conic Gradient Rendering

Conic gradients are rasterized to bitmap and injected as SVG <pattern> elements because SVG has no native conic gradient primitive.

Playground (browser): When WebGPU is available (Chrome 113+), all conic gradients render through a WGSL fragment shader. This enables innerRadius/innerFill and consistent quality. Rendered textures are cached — unchanged gradients skip re-rendering. When WebGPU is unavailable (Firefox, Safari), the playground falls back to Canvas 2D's createConicGradient(), which does not support innerRadius or innerFill.

CLI: Conic gradients render as wedge-shaped SVG paths (pure math, no GPU). The innerRadius and innerFill properties are ignored with a warning.

Mesh Gradient

Create a mesh gradient with an ID, dimensions, and grid size:

let mesh = MeshGradient('terrain', 200, 200, 4, 3) {|g|
  g.getPoint(0, 0).color = Color('#264653');
  g.getPoint(0, 3).color = Color('#2a9d8f');
  g.getPoint(2, 0).color = Color('#e9c46a');
  g.getPoint(2, 3).color = Color('#e63946');
};

Constructor signature: MeshGradient(id, width, height, cols, rows) — creates a rows × cols grid of control points evenly spaced across the given dimensions.

  • cols and rows must be >= 2 (at least one patch)
  • All points start transparent (oklch(0 0 0 / 0))
  • The trailing block {|g| ... } is optional

Grid Access Methods

Method Arguments Returns Description
getPoint(row, col) row, col (numbers) MeshPoint Single control point at grid position
getRow(row) row (number) Array of MeshPoints All points in a row
getCol(col) col (number) Array of MeshPoints All points in a column
colorAll(color) Color value Set every point to the same color

MeshPoint Properties

Each point returned by getPoint, getRow, or getCol has:

Property Read Write Type
x yes yes number
y yes yes number
color yes yes Color

MeshPoint Methods

Method Arguments Description
translate(dx, dy) numbers Shift the point position

Mesh Gradient Properties

Expression Returns
mesh.id The gradient's string ID
mesh.cols Number of columns
mesh.rows Number of rows
mesh.width Width in user-space units
mesh.height Height in user-space units

Mesh Gradient Example

let m = MeshGradient('heat', 200, 200, 3, 3) {|g|
  // Color the corners
  g.getPoint(0, 0).color = Color('#264653');
  g.getPoint(0, 2).color = Color('#2a9d8f');
  g.getPoint(2, 0).color = Color('#e9c46a');
  g.getPoint(2, 2).color = Color('#e63946');

  // Shift a point for artistic control
  g.getPoint(1, 1).translate(10, -5);
  g.getPoint(1, 1).color = Color('#f4a261');
};

define PathLayer('bg') ${ fill: m; stroke: none; }
layer('bg').apply {
  M 0 0 L 200 0 L 200 200 L 0 200 Z
}

Rendering

Mesh gradients are rasterized via WebGPU using bilinear patch interpolation. Each quad cell in the grid is rendered as a smooth color blend between its four corner points.

  • Playground: WebGPU shader renders each patch; the result is injected as <pattern><image/></pattern>, same as conic gradients.
  • CLI: Mesh gradients are not supported in the CLI wedge-path renderer. A warning is emitted and the gradient renders as transparent.

Freeform Gradient

Create a freeform (scattered-point) gradient with an ID and dimensions:

let ff = FreeformGradient('glow', 200, 200) {|g|
  g.point(100, 100, Color('#ffffff'));
  g.point(0, 0, Color('#264653'));
  g.point(200, 0, Color('#2a9d8f'));
  g.point(200, 200, Color('#e63946'));
  g.point(0, 200, Color('#e9c46a'));
};

Constructor signature: FreeformGradient(id, width, height) — creates an empty gradient canvas. Add points with .point(x, y, color).

Methods

Method Arguments Description
point(x, y, color) x, y (numbers), color (Color) Add a color point at the given position

Freeform Gradient Properties

Expression Returns
ff.id The gradient's string ID
ff.width Width in user-space units
ff.height Height in user-space units
ff.falloff Distance falloff exponent (default 2.0)

Falloff

The falloff property controls how quickly colors blend with distance. Higher values create sharper boundaries around each point; lower values create smoother blends:

ff.falloff = 1.0;   // very smooth, linear falloff
ff.falloff = 2.0;   // default — inverse-square (natural)
ff.falloff = 4.0;   // tight halos around each point

falloff must be a positive number.

Freeform Gradient Example

let nebula = FreeformGradient('nebula', 300, 300) {|g|
  g.point(150, 150, Color('#ffffff'));
  g.point(50, 80, Color('#e63946'));
  g.point(250, 80, Color('#2a9d8f'));
  g.point(80, 250, Color('#f4a261'));
  g.point(220, 250, Color('#264653'));
};
nebula.falloff = 3.0;

define PathLayer('bg') ${ fill: nebula; stroke: none; }
layer('bg').apply {
  M 0 0 L 300 0 L 300 300 L 0 300 Z
}

Rendering

Freeform gradients are rasterized via WebGPU using inverse-distance weighted interpolation. Each pixel's color is a weighted average of all control points, where the weight is 1 / distance^falloff.

  • Playground: WebGPU shader computes IDW per-pixel; the result is injected as <pattern><image/></pattern>.
  • CLI: Freeform gradients are not supported in the CLI. A warning is emitted and the gradient renders as transparent.

A warning is also emitted at compile time if a freeform gradient has fewer than 2 points.

Error Handling

Error Cause
MeshGradient() expects 5 arguments Wrong argument count
MeshGradient() first argument must be a string Non-string ID
MeshGradient() width, height, cols, rows must be numbers Non-numeric dimensions
MeshGradient() cols and rows must be >= 2 Grid too small
FreeformGradient() expects 3 arguments Wrong argument count
FreeformGradient() first argument must be a string Non-string ID
FreeformGradient() width and height must be numbers Non-numeric dimensions
getPoint(row, col) out of bounds Index outside grid
getRow(row) out of bounds Row index outside grid
getCol(col) out of bounds Column index outside grid
point() expects 3 arguments (x, y, color) Wrong argument count
FreeformGradient falloff must be positive Non-positive falloff

TopoGradient

Topological gradients define smooth surfaces using closed-path contours at specific elevations, like topographic map contour lines rendered as a smooth gradient. Each contour carries its own color, creating a natural mapping from shape to color.

Constructor

TopoGradient(id, width, height)
Argument Type Description
id string Unique gradient identifier
width number Gradient coordinate width
height number Gradient coordinate height

Contours

Each contour defines a closed path at a specific elevation with a color. Contours are the color stops of a topological gradient — the gradient interpolates between them based on distance.

g.contour(projectedPath, elevation, color)
Argument Type Description
projectedPath ProjectedPathValue Closed path from .project(x, y)
elevation number Elevation level (0–1)
color Color Color at this elevation

The path must be closed (end with closePath()). Use @{ ... } path blocks with .project(x, y) to position contours in absolute space.

Properties

Property Read Write Type Default Description
id yes no string Gradient ID
width yes no number Render width
height yes no number Render height
easing yes yes string 'linear' Easing: linear, smoothstep, ease-in, ease-out, ease-in-out
interpolation yes yes string 'srgb' Color interpolation space
method yes yes string 'distance' Solver: 'distance' (SDF-based) or 'laplace' (Jacobi iteration)
iterations yes yes number 200 Jacobi iterations for Laplace solver (range: 1–2000). Only meaningful when method = 'laplace'; ignored by 'distance'. Higher values produce smoother results but take longer to compute.
baseColor yes yes Color Color outside all contours (elevation 0)

Basic Example

// Define contour shapes
let shore = @{
  M(0, 0)
  C(100, -40, 250, 30, 300, 100)
  C(270, 210, 30, 220, 0, 0)
  closePath()
};

let peak = @{ circle(0, 0, 40); closePath() };

let topo = TopoGradient('terrain', 400, 300) {|g|
  g.contour(shore.project(50, 50), 0.3, Color('#f9e79f'))
  g.contour(shore.scale(0.7, 0.7).project(100, 90), 0.55, Color('#27ae60'))
  g.contour(peak.project(200, 150), 0.8, Color('#6e2c00'))
};
topo.baseColor = Color('#1a5276');
topo.easing = 'smoothstep';

define PathLayer('bg') ${ fill: topo; }
layer('bg').apply { rect(0, 0, 400, 300) }

Programmatic Contours

Contours can be generated procedurally using loops:

let topo = TopoGradient('rings', 400, 400) {|g|
  for ([level, i] in [0.2, 0.4, 0.6, 0.8]) {
    let r = calc(150 - i * 35);
    let ring = @{ circle(0, 0, r); closePath() };
    g.contour(ring.project(200, 200), level, Color('#27ae60'))
  }
};
topo.baseColor = Color('#1a5276');

Multiple Peaks / Islands

Non-nested contours at the same elevation create separate features. Each pixel's elevation is determined by its innermost containing contour.

let topo = TopoGradient('archipelago', 600, 400) {|g|
  // Main island
  g.contour(mainIsland.project(100, 100), 0.35, Color('#f9e79f'))
  g.contour(mainPeak.project(180, 160), 0.7, Color('#6e2c00'))

  // Small island (separate, not nested)
  g.contour(smallIsland.project(450, 280), 0.35, Color('#f9e79f'))
  g.contour(smallPeak.project(460, 290), 0.6, Color('#27ae60'))
};
topo.baseColor = Color('#1a5276');

Algorithm

TopoGradient supports two solver methods for computing the elevation field.

Distance Solver (method = 'distance')

The default method uses distance-based SDF (Signed Distance Field) interpolation:

  1. Containment test: For each contour, ray-cast to determine if the pixel is inside (even-odd rule)
  2. Floor elevation: Highest elevation among all contours containing the pixel
  3. Ceiling elevation: Lowest elevation among contours NOT containing the pixel but above the floor
  4. Distance interpolation: Compute minimum distances to floor and ceiling boundaries, interpolate elevation
  5. Easing: Apply the easing function to the interpolation parameter
  6. Color lookup: Sample the color ramp (built from contour colors sorted by elevation)

Laplace Solver (method = 'laplace')

The Laplace solver computes the mathematically smoothest possible surface between contours by solving the Laplace equation ∇²h = 0 with contour pixels as boundary conditions. This produces results like a rubber sheet stretched between fixed-elevation boundaries.

The solver uses Jacobi iteration: each non-boundary pixel is repeatedly replaced with the average of its 4 neighbors until the field converges. The iterations property controls how many passes are performed (default: 200).

Distance vs Laplace comparison:

  • Distance (SDF): Fast, uses signed distance blending with smooth transition zones. Produces concentric-like gradients that follow contour shapes. Best for: decorative gradients, radial-style effects.
  • Laplace: Solves for the harmonic function, producing physically correct potential field flow. Elevation changes smoothly around corners and between non-nested contours. Best for: terrain/height maps, natural-looking blends, multi-peak topologies.
let s = @{ circle(0, 0, 80); closePath() };
let topo = TopoGradient('terrain', 400, 300) {|g|
  g.contour(s.project(200, 150), 0.3, Color('#2ecc71'))
  g.contour(s.project(200, 150, 0.5, 0.5), 0.7, Color('#e74c3c'))
};
topo.method = 'laplace';
topo.iterations = 300;
topo.baseColor = Color('#1a5276');

Rendering

TopoGradient is rasterized per-pixel:

  • Playground: WebGPU shader with SDF computation (fast); Canvas 2D fallback on Firefox/Safari (slower)
  • CLI: Warning emitted, solid-color approximation rendered

Error Handling

Error Cause
TopoGradient() expects 3 arguments Wrong argument count
TopoGradient() first argument must be a string Non-string ID
TopoGradient() width and height must be numbers Non-numeric dimensions
.contour() expects 3 arguments Wrong argument count
.contour() first argument must be a ProjectedPathValue Non-projected path
.contour() elevation must be between 0 and 1 Out-of-range elevation
.contour() third argument must be a Color value Non-Color color
.contour() path must be closed Path not ending with closePath()
TopoGradient easing must be one of: ... Invalid easing value
TopoGradient iterations must be a number When setting iterations to a non-number
TopoGradient iterations must be between 1 and 2000 When iterations is out of range

CSSVar Type

CSSVar creates CSS custom property references (var()) that can be used in style blocks. This lets SVGs generated by Pathogen be parameterized by the consuming page's CSS.

Constructor

let v = CSSVar('--primary');                    // no fallback
let v = CSSVar('--primary', '#e63946');         // string fallback
let v = CSSVar('--primary', Color('#e63946'));  // Color fallback

The variable name must start with --. The optional fallback can be a plain string or a Color value (which auto-converts to its CSS representation).

Properties

Property Type Description
.var string The variable name (e.g. --primary)
.fallback string or null The fallback value, or null if none
.css string The full var() expression
let v = CSSVar('--primary', '#e63946');
log(v.var);       // --primary
log(v.fallback);  // #e63946
log(v.css);       // var(--primary, #e63946)

Style Blocks

CSSVar values auto-convert in style blocks — no .css needed:

let fg = CSSVar('--foreground', '#333');

define PathLayer('main') ${ stroke: fg; fill: CSSVar('--fill', 'none'); }

This produces stroke="var(--foreground, #333)" and fill="var(--fill, none)" in the SVG output.

Composition with Color

CSSVar composes with the Color type for typed fallbacks:

let brand = Color('#e63946');
let fg = CSSVar('--primary', brand);
// fg.css → var(--primary, #e63946)

Display

log() displays CSSVar values in constructor form:

let v = CSSVar('--primary', '#e63946');
log(v);  // CSSVar(--primary, #e63946)

let v2 = CSSVar('--bg');
log(v2);  // CSSVar(--bg)

Template literals also use this form:

let v = CSSVar('--primary', '#e63946');
log(`color: ${v}`);  // color: CSSVar(--primary, #e63946)

Masks and Clip Paths

Masks and clip paths are SVG <defs> elements that control visibility of layers. They're created with Mask() and ClipPath() constructors and referenced from layer style blocks.

Masks

A mask uses luminance to control visibility — white areas are fully visible, black areas are hidden, and gray values create partial transparency.

Creating a Mask

let m = Mask('my-mask');

The argument is the mask's ID string. IDs must be unique across all masks and clip paths.

Appending Paths

Use .append(path, styles?) to add path elements to the mask:

let base = @{ m 0 0 l 200 0 l 0 200 l -200 0 z };
let hole = @{ m 50 50 l 100 0 l 0 100 l -100 0 z };

let m = Mask('reveal');
m.append(base, ${ fill: white; });    // visible area
m.append(hole, ${ fill: black; });    // hidden cutout

The first argument accepts either a PathBlock or a ProjectedPath. PathBlocks are automatically projected at the origin (0, 0). The optional second argument is a style block for the path element.

Using a Mask

Reference the mask from a layer's style block using the .id property:

define PathLayer('art') ${ mask: m.id; }
layer('art').apply {
  M 10 10 L 190 190
}

The mask property automatically wraps the ID with url(#...), so m.id (which returns 'reveal') becomes mask: url(#reveal) in the output.

Full Example

// Define mask geometry
let fullRect = @{ m 0 0 l 200 0 l 0 200 l -200 0 z };
let circle = @{ m 100 50 a 50 50 0 1 1 0 100 a 50 50 0 1 1 0 -100 };

// Create mask: white = visible, black = hidden
let m = Mask('circle-reveal');
m.append(fullRect, ${ fill: black; });
m.append(circle, ${ fill: white; });

// Apply mask to layer
define PathLayer('drawing') ${ mask: m.id; stroke: #333; stroke-width: 2; }
layer('drawing').apply {
  for (i in 0..20) {
    M 0 calc(i * 10)
    L 200 calc(i * 10)
  }
}

This draws horizontal lines that are only visible inside the circular mask.

Clip Paths

A clip path uses geometry to clip content — anything inside the path is visible, everything outside is hidden. Unlike masks, clip paths don't use styles (they're purely geometric).

Creating a Clip Path

let c = ClipPath('my-clip');

Appending Paths

Use .append(path) to add path elements. No styles parameter — clip paths are geometry-only:

let shape = @{ m 20 20 l 160 0 l 0 160 l -160 0 z };
let c = ClipPath('frame');
c.append(shape);

Using a Clip Path

define PathLayer('scene') ${ clip-path: c.id; }
layer('scene').apply {
  M 0 0 L 200 200
}

Like masks, the clip-path property automatically wraps the ID with url(#...).

Auto-Wrapping

The following CSS properties automatically wrap bare ID strings with url(#...):

  • mask
  • clip-path
  • filter
  • marker-start
  • marker-mid
  • marker-end

If the value already starts with url(, it's left as-is:

// These produce the same output:
define PathLayer('a') ${ mask: m.id; }         // m.id → 'my-mask' → url(#my-mask)
define PathLayer('b') ${ mask: url(#my-mask); } // already wrapped, left as-is

Properties

Property Returns Description
.id string The raw ID string passed to the constructor

Methods

Method Applies To Description
.append(path, styles?) Mask Add a path element with optional styles
.append(path) ClipPath Add a path element (no styles)

Error Handling

  • Duplicate IDs across masks and clip paths throw an error
  • Constructor requires exactly one string argument
  • .append() requires a PathBlock or ProjectedPath as the first argument
  • Mask .append() requires a style block as the optional second argument

Objects

Objects are key-value containers for grouping related data — coordinates, configuration, metadata, or any structured values.

Object Literals

Create objects with curly braces and key: value pairs:

let obj = {};
let point = { x: 50, y: 80 };
let config = { name: 'Dave', age: 32, cats: ['foo', 'bar', 'baz'] };

Keys can be identifiers or string literals. Trailing commas are allowed:

let obj = {
  'first-name': 'Alice',
  lastName: 'Smith',
  age: 30,
};

Objects can be nested:

let shape = {
  center: { x: 100, y: 100 },
  radius: 50,
};

Reading Properties

Dot notation — for identifier keys:

let x = point.x;       // 50
let r = shape.radius;   // 50

Bracket notation — for any string key, including dynamic expressions:

let x = point['x'];     // 50

let key = 'name';
let val = config[key];   // 'Dave'

Accessing a key that doesn't exist returns null:

let missing = point.z;       // null
let also = point['nope'];    // null

The length property returns the number of keys:

let size = point.length;  // 2

Writing Properties

Use bracket notation to set or update properties:

let obj = {};
obj['x'] = 10;
obj['y'] = 20;
obj['x'] = 99;  // overwrite

This also works for updating array elements:

let arr = [1, 2, 3];
arr[0] = 99;  // arr is now [99, 2, 3]

Checking Key Existence

Use .has() to check if a key exists:

let obj = { name: 'Alice' };
if (obj.has('name')) {
  // true
}
if (obj.has('age')) {
  // false
}

Object Namespace Functions

The Object namespace provides utility functions:

Object.keys(obj)

Returns an array of all keys:

let obj = { a: 1, b: 2, c: 3 };
let keys = Object.keys(obj);  // ['a', 'b', 'c']

Object.values(obj)

Returns an array of all values:

let vals = Object.values(obj);  // [1, 2, 3]

Object.entries(obj)

Returns an array of [key, value] pairs:

let entries = Object.entries(obj);  // [['a', 1], ['b', 2], ['c', 3]]

Object.delete(obj, key)

Removes a key from the object. Returns the deleted value, or null if the key didn't exist:

let obj = { x: 10, y: 20 };
let deleted = Object.delete(obj, 'x');  // 10
// obj is now { y: 20 }

Iterating Over Objects

Keys only

let obj = { x: 10, y: 20 };
for (key in obj) {
  log(key);  // 'x', then 'y'
}

Key-value pairs

for ([key, value] in obj) {
  log(key, value);  // 'x' 10, then 'y' 20
}

This also works with Object.entries():

for ([key, value] in Object.entries(obj)) {
  log(key, value);
}

Reference Semantics

Objects use reference semantics (like arrays). Assigning an object to another variable shares the same underlying data:

let a = { x: 1 };
let b = a;
b['x'] = 99;
log(a.x);  // 99 — both a and b point to the same object

Merging Objects (<<)

The << operator creates a new object by merging two objects together. Properties from the right side override those on the left:

let a = { x: 1, y: 2 };
let b = { y: 99, z: 3 };
let merged = a << b;
log(merged);  // {x: 1, y: 99, z: 3}

The original objects are not modified:

log(a);  // {x: 1, y: 2} — unchanged

Multiple merges can be chained (evaluated left-to-right):

let defaults = { stroke: 'black', width: 2, fill: 'none' };
let theme = { stroke: 'red' };
let overrides = { width: 4 };
let final = defaults << theme << overrides;
// {stroke: 'red', width: 4, fill: 'none'}

Merge is shallow — nested objects are shared by reference, not deep-copied:

let inner = { val: 1 };
let a = { nested: inner };
let b = a << {};
b.nested['val'] = 99;
log(a.nested.val);  // 99 — same inner object

Using Objects with Path Commands

Objects are natural containers for coordinates and configuration:

let start = { x: 10, y: 20 };
let end = { x: 180, y: 160 };

M start.x start.y
L end.x end.y

Debug & Console

The playground includes debugging tools to help you understand how your code executes and inspect values during evaluation.

Console Output

Click the Console button in the header to view debug output.

log() Function

Use log() to inspect values during execution:

log("message")           // String message
log(myVar)               // Variable with label
log("pos:", ctx.position) // Multiple args
log(ctx)                 // Full context object

Output Format

String arguments display as-is. Other expressions show a label with the source:

log("radius is", r)
// Output:
// radius is
// r = 50

Objects are expandable in the console - click the arrow to explore nested properties.

ctx Object

The ctx object tracks path state during evaluation:

ctx.position

Current pen position after the last command.

Property Description
ctx.position.x X coordinate
ctx.position.y Y coordinate
M 100 50
log(ctx.position)  // {x: 100, y: 50}
L 150 75
log(ctx.position)  // {x: 150, y: 75}

ctx.start

Subpath start position (set by M/m, used by Z).

Property Description
ctx.start.x X coordinate
ctx.start.y Y coordinate

ctx.commands

Array of all executed commands with their positions:

// Each entry contains:
{
  command: "L",        // Command letter
  args: [150, 75],     // Evaluated arguments
  start: {x: 100, y: 50},
  end: {x: 150, y: 75}
}

Using ctx in Paths

Access position values with calc():

M 50 50
// Draw relative to current position
L calc(ctx.position.x + 30) ctx.position.y
circle(ctx.position.x, ctx.position.y, 5)

Example: Debug a Loop

M 20 100
for (i in 0..4) {
  log("iteration", i, ctx.position)
  L calc(ctx.position.x + 40) 100
}

This logs the iteration number and current position at each step, helping you trace how the path is constructed.

CLI Reference

The svg-path-extended CLI compiles extended SVG path syntax into standard SVG path strings or complete SVG files.

Installation

npm install -g svg-path-extended

Or use with npx:

npx svg-path-extended [options]

Basic Usage

Compile a File

svg-path-extended input.svgx

Or with the explicit flag:

svg-path-extended --src=input.svgx

Compile Inline Code

svg-path-extended -e 'circle(100, 100, 50)'

Read from Stdin

echo 'let x = 50; circle(x, x, 25)' | svg-path-extended -
cat myfile.svgx | svg-path-extended -

Output Options

Output Path Data to File

svg-path-extended --src=input.svgx -o output.txt
svg-path-extended --src=input.svgx --output output.txt

Output as Complete SVG File

Generate a complete SVG file with the path embedded:

svg-path-extended --src=input.svgx --output-svg-file=output.svg

This creates a ready-to-use SVG file that can be opened in any browser or image viewer.

Log Output

Pathogen programs can use log() to produce diagnostic output. By default, the CLI discards log entries. Two flags expose them:

Print to stderr

svg-path-extended -e 'let x = 42; log(x); M x 0' --print-logs

Output on stderr:

[line 1] x = 42

The path data still goes to stdout, so logs don't interfere with piping:

svg-path-extended -e 'log("hello"); circle(50, 50, 25)' --print-logs > output.txt

Write structured JSON

svg-path-extended --src=input.pathogen --log-file=logs.json

This writes the full LogEntry[] array with line numbers and typed parts:

[
  {
    "line": 3,
    "parts": [
      { "type": "value", "label": "x", "value": "42" }
    ]
  }
]

Both flags can be combined:

svg-path-extended --src=debug.pathogen --print-logs --log-file=logs.json --output-svg-file=out.svg

Annotated Output

Use --annotated to get a human-readable debug output that shows:

  • Original comments preserved in place
  • Loop iterations with line numbers
  • Function call annotations with expanded output
  • Each path command on its own line

This is useful for debugging complex path generation or understanding how your code produces its output.

Basic Usage

svg-path-extended -e 'for (i in 0..3) { M i 0 }' --annotated

Output:

//--- for (i in 0..3) from line 1
  //--- iteration 0
  M 0 0
  //--- iteration 1
  M 1 0
  //--- iteration 2
  M 2 0
  //--- iteration 3
  M 3 0

With Comments

svg-path-extended -e '// Draw points
for (i in 0..3) { M i 0 }' --annotated

Output:

// Draw points

//--- for (i in 0..3) from line 2
  //--- iteration 0
  M 0 0
  //--- iteration 1
  M 1 0
  //--- iteration 2
  M 2 0
  //--- iteration 3
  M 3 0

Loop Truncation

Long loops (>10 iterations) are automatically truncated to show the first 3 and last 3 iterations:

svg-path-extended -e 'for (i in 0..100) { M i 0 }' --annotated

Output:

//--- for (i in 0..100) from line 1
  //--- iteration 0
  M 0 0
  //--- iteration 1
  M 1 0
  //--- iteration 2
  M 2 0
  ... 95 more iterations ...
  //--- iteration 98
  M 98 0
  //--- iteration 99
  M 99 0
  //--- iteration 100
  M 100 0

Function Call Annotations

Function calls show their name, arguments, and expanded output:

svg-path-extended -e 'circle(50, 50, 25)' --annotated

Output:

//--- circle(50, 50, 25) called from line 1
  M 25 50
  A 25 25 0 1 1 75 50
  A 25 25 0 1 1 25 50

Save to File

svg-path-extended --src=complex.svgx --annotated -o debug-output.txt

SVG Styling Options

When using --output-svg-file, you can customize the appearance:

Option Default Description
--stroke=<color> #000 Stroke color
--fill=<color> none Fill color
--stroke-width=<n> 2 Stroke width
--viewBox=<box> 0 0 200 200 SVG viewBox
--width=<w> 200 SVG width
--height=<h> 200 SVG height

Examples

Red circle with no fill:

svg-path-extended -e 'circle(100, 100, 50)' \
  --output-svg-file=circle.svg \
  --stroke=red \
  --stroke-width=3

Blue filled polygon:

svg-path-extended -e 'polygon(100, 100, 80, 6)' \
  --output-svg-file=hexagon.svg \
  --stroke=navy \
  --fill=lightblue \
  --stroke-width=2

Large canvas with custom viewBox:

svg-path-extended --src=complex.svgx \
  --output-svg-file=output.svg \
  --viewBox="0 0 800 600" \
  --width=800 \
  --height=600

Help and Version

svg-path-extended --help
svg-path-extended -h

svg-path-extended --version
svg-path-extended -v

Exit Codes

Code Meaning
0 Success
1 Error (parse error, file not found, etc.)

File Extensions

By convention, source files use the .svgx extension, but any text file will work.

Examples

Generate a Spiral

svg-path-extended -e '
M 100 100
for (i in 1..50) {
  L calc(100 + cos(i * 0.3) * i * 1.5) calc(100 + sin(i * 0.3) * i * 1.5)
}
' --output-svg-file=spiral.svg --stroke=teal --stroke-width=2

Process Multiple Files

for file in examples/*.svgx; do
  svg-path-extended --src="$file" --output-svg-file="${file%.svgx}.svg"
done

Use in a Build Script

{
  "scripts": {
    "build:icons": "svg-path-extended --src=src/icons.svgx --output-svg-file=dist/icons.svg"
  }
}

Examples

Practical examples showing how to use svg-path-extended for common tasks.

Basic Shapes

Simple Rectangle

rect(10, 10, 180, 80)

Circle

circle(100, 100, 50)

Rounded Rectangle

roundRect(20, 40, 160, 120, 15)

Using Variables

Centered Circle

let width = 200;
let height = 200;
let cx = calc(width / 2);
let cy = calc(height / 2);
let r = 40;

circle(cx, cy, r)

Configurable Star

let centerX = 100;
let centerY = 100;
let outerR = 60;
let innerR = 25;
let points = 5;

star(centerX, centerY, outerR, innerR, points)

Loops and Patterns

Row of Circles

for (i in 0..5) {
  circle(calc(30 + i * 35), 100, 15)
}

Grid of Dots

for (row in 0..5) {
  for (col in 0..5) {
    circle(calc(20 + col * 40), calc(20 + row * 40), 5)
  }
}

Concentric Circles

let cx = 100;
let cy = 100;

for (i in 1..6) {
  circle(cx, cy, calc(i * 15))
}

Trigonometry

Points on a Circle

let cx = 100;
let cy = 100;
let r = 60;
let points = 8;

for (i in 0..points) {
  let angle = calc(i / points * TAU());
  let x = calc(cx + cos(angle) * r);
  let y = calc(cy + sin(angle) * r);
  circle(x, y, 5)
}

Spiral

M 100 100
for (i in 1..100) {
  let angle = calc(i * 0.2);
  let r = calc(i * 0.8);
  L calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)
}

Sine Wave

M 0 100
for (i in 1..40) {
  let x = calc(i * 5);
  let y = calc(100 + sin(i * 0.3) * 30);
  L x y
}

Flower Pattern

let cx = 100;
let cy = 100;
let petalCount = 6;
let petalRadius = 25;
let centerRadius = 15;

// Petals
for (i in 0..petalCount) {
  let angle = calc(i / petalCount * TAU());
  let px = calc(cx + cos(angle) * 35);
  let py = calc(cy + sin(angle) * 35);
  circle(px, py, petalRadius)
}

// Center
circle(cx, cy, centerRadius)

Custom Functions

Reusable Square

fn square(x, y, size) {
  rect(x, y, size, size)
}

square(10, 10, 50)
square(70, 10, 50)
square(130, 10, 50)

Diamond Shape

fn diamond(cx, cy, size) {
  M cx calc(cy - size)
  L calc(cx + size) cy
  L cx calc(cy + size)
  L calc(cx - size) cy
  Z
}

diamond(100, 100, 40)

Arrow

fn arrow(x1, y1, x2, y2, headSize) {
  // Line
  M x1 y1
  L x2 y2

  // Arrowhead (simplified)
  let angle = atan2(calc(y2 - y1), calc(x2 - x1));
  let a1 = calc(angle + 2.5);
  let a2 = calc(angle - 2.5);

  M x2 y2
  L calc(x2 - cos(a1) * headSize) calc(y2 - sin(a1) * headSize)
  M x2 y2
  L calc(x2 - cos(a2) * headSize) calc(y2 - sin(a2) * headSize)
}

arrow(20, 100, 180, 100, 15)

Conditionals

Size-Based Shape

let size = 80;

if (size > 50) {
  circle(100, 100, size)
} else {
  rect(calc(100 - size / 2), calc(100 - size / 2), size, size)
}

Alternating Pattern

for (i in 0..10) {
  let x = calc(20 + i * 18);
  if (calc(i % 2) == 0) {
    circle(x, 100, 8)
  } else {
    rect(calc(x - 6), 94, 12, 12)
  }
}

Complex Examples

Gear Shape

let cx = 100;
let cy = 100;
let innerR = 30;
let outerR = 50;
let teeth = 12;

M calc(cx + outerR) cy

for (i in 0..teeth) {
  let a1 = calc(i / teeth * TAU());
  let a2 = calc((i + 0.3) / teeth * TAU());
  let a3 = calc((i + 0.5) / teeth * TAU());
  let a4 = calc((i + 0.8) / teeth * TAU());

  L calc(cx + cos(a1) * outerR) calc(cy + sin(a1) * outerR)
  L calc(cx + cos(a2) * outerR) calc(cy + sin(a2) * outerR)
  L calc(cx + cos(a3) * innerR) calc(cy + sin(a3) * innerR)
  L calc(cx + cos(a4) * innerR) calc(cy + sin(a4) * innerR)
}

Z

// Center hole
circle(cx, cy, 10)

Recursive-Style Tree (using loops)

// Simple branching pattern
fn branch(x, y, length, angle, depth) {
  let x2 = calc(x + cos(angle) * length);
  let y2 = calc(y + sin(angle) * length);
  M x y
  L x2 y2
}

let startX = 100;
let startY = 180;

// Trunk
M startX startY
L startX 120

// Main branches
for (i in 0..5) {
  let angle = calc(-1.57 + (i - 2) * 0.4);
  let len = calc(30 - abs(i - 2) * 5);
  branch(startX, 120, len, angle, 0)
}

Tips

  1. Start simple: Build complex shapes from simple parts
  2. Use variables: Makes code readable and adjustable
  3. Extract functions: Reuse common patterns
  4. Test incrementally: Generate SVGs often to see results
  5. Use comments: Document your intent for complex sections