Getting Started

pathogen-lang 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:

define ViewBox(0, 0, 200, 200);
define default PathLayer('main-path-layer') ${
  fill: #bbb;
  stroke: #222;
  stroke-width: 1;
};

// 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

Every Pathogen program starts with a define ViewBox declaring the canvas, and typically one or more layer definitions describing how strokes and fills should look.

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 pathogen-lang?

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 pathogen-lang, you can use variables and loops:

// pathogen-lang - 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

pathogen-lang 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]];

Spread (...)

Use the spread operator to expand an array's elements into another array literal:

let a = [1, 2, 3];
let b = [0, ...a, 4, 5];     // [0, 1, 2, 3, 4, 5]
let c = [...a, ...b];         // combine two arrays

Spread works anywhere inside an array literal and can be mixed with regular elements:

let head = [10, 20];
let tail = [40, 50];
let full = [...head, 30, ...tail];  // [10, 20, 30, 40, 50]

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.

Destructuring

Extract array elements into individual variables with destructuring in let declarations:

let [a, b, c] = [1, 2, 3];
log(a);  // 1
log(b);  // 2
log(c);  // 3

If the array has more elements than bindings, extras are silently ignored:

let [first, second] = [10, 20, 30, 40];
// first is 10, second is 20 — 30 and 40 ignored

If the array has fewer elements than bindings, missing values are null:

let [x, y, z] = [1, 2];
// x is 1, y is 2, z is null

Use the rest pattern (...name) to collect remaining elements into a new array:

let [head, ...tail] = [1, 2, 3, 4, 5];
// head is 1, tail is [2, 3, 4, 5]

let [only, ...rest] = [42];
// only is 42, rest is []

The rest pattern must be the last binding in the destructuring pattern.

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

pathogen-lang 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:

Not to be confused with the Grid() constructor — that's a data container for 2D values mapped to canvas coordinates (flow fields, heatmaps, sampling). The functions below produce SVG path data for visual lattices.

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

ViewBox

Every Pathogen program renders into an SVG with a viewBox. The define ViewBox statement specifies that viewBox directly in code, making the program self-contained and reproducible across the CLI, playground, and VS Code preview.

Syntax

define ViewBox(originX, originY, width, height);

The four arguments become the SVG viewBox="originX originY width height" attribute on the root <svg> element. The root width and height attributes default to the same width and height values.

define ViewBox(0, 0, 200, 200);
M 50 50 L 150 150

Renders to <svg viewBox="0 0 200 200" width="200" height="200">…</svg>.

Arguments

Arguments are expressions and may use variables, calc(), or any other expression form:

let W = 400;
let H = 300;
define ViewBox(0, 0, W, H);
define ViewBox(0, 0, calc(100 * 4), calc(100 * 3));

Negative Origin

originX and originY may be negative — useful for centering geometry around (0, 0):

define ViewBox(-100, -100, 200, 200);
M -50 -50 L 50 50

Default ViewBox

If a program contains no define ViewBox statement, the viewBox defaults to 0 0 200 200.

Placement

define ViewBox is a top-level statement. It may appear anywhere among other top-level statements, but not inside a layer().apply { } block, a path block, or a text block.

// OK
define ViewBox(0, 0, 200, 200);
define default PathLayer('main') ${ stroke: #222; };
M 0 0 L 200 200

// Error: ViewBox must appear at top level
layer('main').apply {
  define ViewBox(0, 0, 200, 200);
  M 0 0
}

Errors

The compiler rejects:

  • Duplicate define ViewBox — only one viewBox per program.
  • Zero or negative width or height — these are invalid SVG dimensions.
  • default modifierdefine default ViewBox(…) is not allowed; only PathLayer and TextLayer accept default.
  • Non-numeric arguments — every argument must evaluate to a finite number.

Precedence with the CLI

The CLI accepts --viewBox, --width, and --height flags. When the source contains a define ViewBox statement, the source wins; the CLI flags are used only when the source does not define a viewBox:

Source has define ViewBox? --viewBox flag? Resulting viewBox
Yes (anything) From define ViewBox
No Yes From --viewBox
No No 0 0 200 200

This lets inline -e snippets supply a viewBox via the CLI while persistent programs declare it in source.

Why source, not configuration

Storing viewBox in source code (rather than in workspace metadata, comments, or external configuration) keeps a program self-contained: copying the code anywhere reproduces the same image. It also lets editor tooling (completion, hover, formatting) reason about the viewBox the same way it reasons about any other statement.

  • Layers — the rest of the define family (PathLayer, TextLayer, GroupLayer)
  • CLI — using --viewBox/--width/--height flags alongside source-defined viewBox

Layers

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

See also define ViewBox for declaring the SVG viewBox — a sibling define-family statement that controls the canvas dimensions.

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 'pathogen-lang';

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, but no fonts were loaded. If you wrote an @font directive, font loading may have failed earlier — look for a preceding font-loading error.
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: pathogen-lang --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)

Markers

Markers decorate lines and path vertices — arrowheads at the end of a connector, dots at each vertex of a polyline, endpoint caps on a freehand stroke. Define a marker once with the Marker() constructor and attach it to any number of layers via the marker-start, marker-mid, and marker-end style properties; special paint values context-stroke and context-fill let one marker automatically track each line's color.

Markers live in the shared <defs> block alongside gradients, patterns, and masks, and are referenced via url(#id).

Creating a Marker

Use the Marker() constructor with a unique ID and the marker's intrinsic width and height:

let arrow = @{
  m 0 0 l 10 5 l -10 5 z
};

let arrowMarker = Marker('arrowhead', 10, 10) {|m|
  m.append(arrow, ${ fill: Color('#333'); });
};

define PathLayer('line') ${
  stroke: Color('#333');
  stroke-width: 3;
  fill: none;
  marker-end: arrowMarker;
}

layer('line').apply {
  M 40 100 L 360 100
}

Constructor signature: Marker(id, markerWidth, markerHeight)id is a string, markerWidth and markerHeight are numbers in user-space units.

The trailing block {|m| ... } binds the newly-created marker to m. Use m.append(...) inside the block to add the marker's path content.

Appending Paths

Use .append(pathBlock, styles?) to add path geometry to the marker:

m.append(arrow, ${ fill: context-stroke; });
  • pathBlock — a PathBlock (@{ ... }) or ProjectedPath. PathBlocks are automatically projected at the marker's local origin (0, 0).
  • styles — optional style block for the appended path element. Accepts normal colors, Color(...) values, and the special context values context-stroke and context-fill described below.

.append() can be called multiple times to layer multiple shapes inside a single marker.

Using Markers in Styles

Reference a marker in a layer's style block using marker-start, marker-mid, or marker-end:

define PathLayer('line') ${
  stroke: Color('#333');
  stroke-width: 3;
  fill: none;
  marker-start: dotMarker;
  marker-mid: dotMarker;
  marker-end: arrowMarker;
}
  • marker-start — rendered at the first vertex of the path.
  • marker-mid — rendered at every interior vertex (not the first or last).
  • marker-end — rendered at the last vertex of the path.

These properties — along with the shorthand marker — automatically wrap the marker as url(#id), so marker-end: arrowMarker in Pathogen becomes marker-end="url(#arrowhead)" in the output SVG. If you prefer to be explicit, marker-end: arrowMarker.id or marker-end: url(#arrowhead) produce the same result.

The marker shorthand applies the same marker to all three positions:

define PathLayer('vertices') ${
  stroke: Color('#333');
  stroke-width: 2;
  fill: none;
  marker: dotMarker;   // shorthand: applies to start, mid, AND end
}

Default Attribute Values

Markers are created with smart defaults so the simple case just works:

Attribute Default Notes
viewBox 0 0 {markerWidth} {markerHeight} Derived from constructor args
refX markerWidth / 2 Centered horizontally
refY markerHeight / 2 Centered vertically
markerUnits 'strokeWidth' Marker scales with the line's stroke width
orient 'auto' Marker rotates to match path direction
preserveAspectRatio 'xMidYMid meet' Standard SVG default

Mutable Properties

After construction, properties can be reassigned to override the defaults. Numeric values are allowed on refX, refY, and orient; all other properties — plus the symbolic forms of refX/refY/orient — take members of a named enum.

let arrowMarker = Marker('flow-arrow', 12, 12) {|m|
  m.append(arrow, ${ fill: context-stroke; });
};

// Numeric override: position the arrow tip exactly at the endpoint
arrowMarker.refX = 12;
arrowMarker.refY = 6;

// Enum override: align the reference point to the marker's right edge,
// size the marker in absolute user-space units, and flip the start-marker
arrowMarker.refX = MarkerRefX.Right;
arrowMarker.markerUnits = MarkerUnits.UserSpaceOnUse;
arrowMarker.orient = MarkerOrient.AutoStartReverse;
Property Accepts Enum
viewBox string ("minX minY width height")
refX number or enum value MarkerRefX (Left, Center, Right)
refY number or enum value MarkerRefY (Top, Center, Bottom)
markerUnits enum value MarkerUnits (StrokeWidth, UserSpaceOnUse)
orient number (radians) or enum value MarkerOrient (Auto, AutoStartReverse)
preserveAspectRatio enum value MarkerPreserveAspectRatioNone, or {XMin,XMid,XMax}{YMin,YMid,YMax}{Meet,Slice} (e.g. XMidYMidMeet, the default; XMinYMinSlice)

Invalid enum values throw an error that lists the valid options.

Orient

The orient property controls how the marker is rotated at each vertex. It accepts both enum strings and a numeric radian value:

// Rotates to match path direction (the default)
autoMarker.orient = MarkerOrient.Auto;

// Rotates to match path direction, but flips start markers so arrows
// on both ends point outward
reverseMarker.orient = MarkerOrient.AutoStartReverse;

// Fixed angle — 45 degrees in radians
fixedMarker.orient = PI() / 4;

// Explicit zero — always points right
zeroMarker.orient = 0;

Numeric values are interpreted as radians and converted to degrees for the generated SVG attribute.

context-stroke and context-fill

Markers often need to match the color of the line they decorate. SVG provides two special paint values — context-stroke and context-fill — that tell the marker to inherit from its referencing element. Pathogen passes these through as raw strings:

let arrow = @{
  m 0 0 l 10 5 l -10 5 z
};

// One marker reused across many lines; fill picks up each line's stroke color
let arrowMarker = Marker('context-arrow', 10, 10) {|m|
  m.append(arrow, ${ fill: context-stroke; stroke: none; });
};

define PathLayer('red')    ${ stroke: Color('#e63946'); stroke-width: 3; fill: none; marker-end: arrowMarker; }
define PathLayer('orange') ${ stroke: Color('#f77f00'); stroke-width: 3; fill: none; marker-end: arrowMarker; }
define PathLayer('green')  ${ stroke: Color('#2a9d8f'); stroke-width: 3; fill: none; marker-end: arrowMarker; }

layer('red').apply    { M 40 60  L 360 60 }
layer('orange').apply { M 40 130 L 360 130 }
layer('green').apply  { M 40 200 L 360 200 }

Each line renders with an arrowhead in its own stroke color, even though there is only a single Marker definition in <defs>.

Use context-stroke when the marker fill should match the line's stroke color, and context-fill when it should match the line's fill.

Multiple Markers on One Path

A single layer can attach different markers at the start, mid, and end vertices. This is how flow-diagram-style polylines decorate every joint:

let arrow = @{ m 0 0 l 10 5 l -10 5 z };
let dot   = @{ circle(5, 5, 4); };
let ring  = @{ circle(5, 5, 4); };

let arrowMarker = Marker('arrow', 10, 10) {|m|
  m.append(arrow, ${ fill: context-stroke; });
};
let dotMarker = Marker('dot', 10, 10) {|m|
  m.append(dot, ${ fill: context-stroke; });
};
let ringMarker = Marker('ring', 10, 10) {|m|
  m.append(ring, ${ fill: Color('#fff'); stroke: context-stroke; stroke-width: 1.5; });
};

define PathLayer('path1') ${
  stroke: Color('#2a9d8f');
  stroke-width: 3;
  fill: none;
  marker-start: ringMarker;
  marker-mid: dotMarker;
  marker-end: arrowMarker;
}

// Zig-zag exercises start, 3 mid vertices, and end
layer('path1').apply {
  M 40 80 L 120 40 L 200 120 L 280 40 L 360 80
}

Generated SVG Output

The basic arrow example above produces:

<defs>
  <marker id="arrowhead" viewBox="0 0 10 10" markerWidth="10" markerHeight="10" refX="5" refY="5" orient="auto">
    <path d="M 0 0 L 10 5 L 0 10 Z" fill="#333333"/>
  </marker>
</defs>
...
<path d="M 40 100 L 360 100" fill="none" stroke="#333333" stroke-width="3" marker-end="url(#arrowhead)"/>

The marker geometry is stored as absolute path commands inside <defs>, and each layer that references it gets marker-end="url(#id)" (or marker-start / marker-mid) on the output <path> element.

Attributes that match the SVG default are omitted to keep output compact — in the example above, markerUnits="strokeWidth" and preserveAspectRatio="xMidYMid meet" are implied. Assign a non-default value (e.g. arrowMarker.markerUnits = MarkerUnits.UserSpaceOnUse) and the attribute appears in the output.

Properties

Property Returns Description
.id string The ID passed to the constructor
.viewBox string Current viewBox attribute
.markerWidth number Intrinsic width
.markerHeight number Intrinsic height
.refX number or string Reference point X (see Mutable Properties)
.refY number or string Reference point Y
.markerUnits string Current markerUnits value
.orient number or string Current orient (radians if numeric)
.preserveAspectRatio string Current preserveAspectRatio value

Methods

Method Description
.append(pathBlock, styles?) Add a path element to the marker. Accepts PathBlock or ProjectedPath; optional style block.

Auto-Wrapping

These CSS properties automatically wrap marker values as url(#id):

  • marker (shorthand)
  • marker-start
  • marker-mid
  • marker-end

If the value already starts with url(, it's left as-is.

Error Handling

Error Cause
Marker() expects 3 arguments (id, markerWidth, markerHeight) Wrong number of constructor args
Marker() first argument must be a string Non-string ID
Marker() markerWidth and markerHeight arguments must be numbers Non-numeric dimensions
Duplicate defs ID '<id>' Another Mask, ClipPath, Gradient, Pattern, or Marker already uses this ID
Marker.append() expects 1-2 arguments (path, styles?) Wrong number of .append() args
Marker.append() first argument must be a PathBlock or ProjectedPath Passed something other than @{ ... } or a projected path
Marker.append() second argument must be a style block Passed something other than a ${ ... } style block
Unknown Marker method: <name> Called a method other than .append()
Cannot assign to Marker property '<name>' Assigned to a non-mutable property
Marker.refX must be a number or MarkerRefX enum value Assigned an invalid type to refX (same pattern applies to refY, orient, markerUnits, preserveAspectRatio)
Invalid value '<x>' for Marker.<prop>. Valid values: ... Assigned a string that isn't a member of the relevant enum

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

Filters

Filters apply post-render visual effects — film grain, paper texture, glow, emboss, layered depth shadows, inner shadows, pixelation — to any layer. Native CSS filter functions like blur(2px) and brightness(1.2) already work directly inside a style block. Custom filters go further: each one synthesizes a real <filter> definition in the output SVG with a thoughtfully tuned chain of primitives, so you get the look you want without writing <feTurbulence>, <feSpecularLighting>, <feMorphology>, or <feMerge> by hand.

Pathogen ships six custom filter constructors:

Custom filters live in the shared <defs> block alongside gradients, patterns, masks, and markers, and are referenced via url(#id). All six compile to stock SVG filter primitives, so they render identically in the CLI, the playground, and the VS Code preview.

NoiseFilter

NoiseFilter() produces grain, paper texture, speckle, TV static, or a grainy gradient overlay. A single style enum picks the recipe; everything else is an optional knob.

let grain = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Grain;
};

define PathLayer('portrait') ${
  fill: oklch(70% 0.18 30);
  filter: grain;
}

layer('portrait').apply {
  M 50 50
  C 50 100 150 100 150 50
  Z
}

Constructor signature: NoiseFilter() — no positional arguments. The trailing block {|f| ... } binds the new filter to f; assign properties on f to override the preset's defaults.

The filter can be reused: a single let grain = NoiseFilter() {...}; followed by filter: grain; on N layers emits one <filter> definition referenced N times. An anonymous inline form filter: NoiseFilter(); (with default Grain settings) also works. The inline form does not support the trailing configuration block — filter: NoiseFilter() {|f| ...; }; will not parse because the style-block tokenizer stops at the first ;. Assign to a let binding when you need to customise.

NoiseFilterStyle Presets

style selects the primitive chain and a parameter baseline. Override individual properties on f after setting style to fine-tune.

Style Visual Best for
Grain Fine, color-burned grain that respects the source colors Photographic illustrations, portraits
Paper Soft multiplicative texture Posters, document mockups, bookplate art
Speckle Coarse, irregular flecks Risograph, screen-print effects
Static Sharp, high-contrast monochrome noise TV static, glitch backgrounds
Gradient Stitched fractal noise pumped with contrast and overlaid Grainy gradients, atmospheric backgrounds

Per-Style Defaults

The chain shape is the same across all five styles (see Generated SVG Output); per-style differences come from these defaults. Every value is overridable on the bound parameter.

Style turbulence type scale octaves amount monochrome blend contrast stitch
Grain fractalNoise 5.0 6 0.4 true color-burn 1.0 false
Paper fractalNoise 1.0 3 0.5 true multiply 1.0 false
Speckle turbulence 0.3 2 0.6 false multiply 1.0 false
Static fractalNoise 5.0 8 0.7 true hard-light 1.0 false
Gradient fractalNoise 1.0 3 0.6 false overlay 1.7 true
let grainy = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; };
let paper  = NoiseFilter() {|f| f.style = NoiseFilterStyle.Paper; };
let flecks = NoiseFilter() {|f| f.style = NoiseFilterStyle.Speckle; };
let snow   = NoiseFilter() {|f| f.style = NoiseFilterStyle.Static; };
let smudge = NoiseFilter() {|f| f.style = NoiseFilterStyle.Gradient; };

If style is omitted, the filter defaults to Grain.

Properties

After construction, properties on the bound parameter can be reassigned. Defaults come from the chosen style; user assignments take precedence.

Property Type Default Effect
style NoiseFilterStyle Grain Selects the primitive chain and per-property defaults
scale number or NoiseFilterScale per style Grain density. scale maps directly to SVG baseFrequency: higher number → finer, denser pattern; lower number → larger, coarser features. Use the NoiseFilterScale enum for common values (see the table below the property list), or assign a finite positive number directly
octaves integer 1–10 per style Layered noise frequencies. 1 = single smooth pattern; 8+ = fine fractal detail. Each octave compounds render cost — see Browser Caveats
amount number 0–1 per style Visible intensity. 0 = no effect; 1 = full strength. Modulates the alpha of the noise before it blends with the source
monochrome boolean per style When true, strips color variance via feColorMatrix luminanceToAlpha so the grain reads as pure light/dark texture
seed number derived from id Deterministic seed for feTurbulence. See Seed stability below
blend BlendMode per style Final blend mode against the source graphic
contrast number ≥ 0 per style (1.0, 1.7 for Gradient) Post-noise contrast pump. 1 = no pump; higher values produce sharper, sparkier grain. Insert point is just after feTurbulence, so it affects every style
stitch boolean per style (true for Gradient, false otherwise) When true, sets stitchTiles="stitch" to avoid visible tiling seams across large surfaces or repeating textures
let pronounced = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Grain;
  f.amount = 0.7;                       // stronger
  f.scale = NoiseFilterScale.Medium;    // coarser than the Grain default
  f.monochrome = false;                 // keep some color variance
  f.seed = 42;                          // pin to a specific noise seed
};

NoiseFilterScale

NoiseFilterScale packages the three common scale values so they're discoverable through IDE autocompletion. The members evaluate to the same string values the scale write handler accepts directly, so f.scale = NoiseFilterScale.Fine and f.scale = 'fine' are equivalent.

Member Underlying value baseFrequency
NoiseFilterScale.Fine 'fine' 5.0
NoiseFilterScale.Medium 'medium' 1.0
NoiseFilterScale.Coarse 'coarse' 0.3

For values outside these three buckets, assign a finite positive number directly: f.scale = 2.5;.

Seed Stability

seed defaults to a deterministic hash of the filter's auto-generated id. The id follows the pattern pathogen-noise-N, where N is the 1-based index of the NoiseFilter() call in evaluation order. As long as your source file doesn't reorder, add, or remove NoiseFilter() declarations, the seed (and the noise pattern) is stable across compiles.

The footgun: adding a new NoiseFilter() above an existing one shifts every subsequent filter's auto-id, which shifts every derived seed, which visibly changes every existing grain pattern. Set seed explicitly on any filter whose noise pattern you want to lock down across future edits:

let signature = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Grain;
  f.seed = 42;   // stable across edits regardless of declaration order
};

Reading Properties

NoiseFilter values support read-side property access — useful for reusing the same id elsewhere, conditional logic, or debug output:

let grain = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Speckle;
  f.amount = 0.6;
};

log(grain.id);       // → "pathogen-noise-1"
log(grain.style);    // → "speckle"
log(grain.amount);   // → 0.6
log(grain.blend);    // → "multiply"

All properties from the table above are readable.

GlowFilter

GlowFilter() produces a soft glow around (or inside) a painted shape. The mode property picks outer vs. inner; the rest of the knobs are color, radius, spread, and opacity.

let glow = GlowFilter() {|f|
  f.mode = GlowMode.Outer;
  f.color = oklch(85% 0.20 60);
  f.radius = 8;
  f.opacity = 0.8;
};

define PathLayer('star') ${
  fill: oklch(70% 0.20 30);
  filter: glow;
}
layer('star').apply { star(100, 100, 50, 22, 5); }

Constructor signature: GlowFilter() — no positional arguments. The trailing block {|f| ... } binds the new filter to f.

Properties

Property Type Default Effect
mode GlowMode GlowMode.Outer Outer halo (default) or inner light along the inside edge of the shape
color Color white Glow color
radius number ≥ 0 4 Blur radius (stdDeviation); larger values produce a softer, wider glow
spread number ≥ 0 0 Pre-blur morphology: dilates (Outer mode) or erodes (Inner mode) the silhouette before blurring
opacity number 0–1 0.8 Glow strength

Generated SVG Output

For GlowFilter() {|f| f.mode = GlowMode.Outer; f.color = oklch(85% 0.20 60); f.radius = 8; }:

<filter id="pathogen-glow-1" x="-50%" y="-50%" width="200%" height="200%">
  <feGaussianBlur in="SourceAlpha" stdDeviation="8" result="blur"/>
  <feFlood flood-color="#ffa800" flood-opacity="0.8" result="flood"/>
  <feComposite in="flood" in2="blur" operator="in" result="coloredGlow"/>
  <feMerge>
    <feMergeNode in="coloredGlow"/>
    <feMergeNode in="SourceGraphic"/>
  </feMerge>
</filter>

(The OKLCH color literal is resolved to a hex string at compile time, so the emitted SVG contains a concrete color value rather than a oklch(...) CSS function.)

When spread > 0, an extra feMorphology is inserted before feGaussianBlur to expand (Outer) or contract (Inner) the silhouette. In Inner mode, a feComposite operator="out" step inverts the blur so the glow rides the inside edge.

The filter region is -50% / 200% so outer glows have room to extend beyond the painted bounds.

EmbossFilter

EmbossFilter() produces an embossed appearance via SVG's feSpecularLighting primitive — the painted shape catches simulated light from a distant source and gains highlights along the lit edges.

let emboss = EmbossFilter() {|f|
  f.angle = 135deg;
  f.depth = 3;
  f.strength = 1.0;
};

define PathLayer('badge') ${
  fill: oklch(75% 0.15 60);
  filter: emboss;
}
layer('badge').apply { circle(100, 100, 60); }

Constructor signature: EmbossFilter() — no positional arguments. Configure via the trailing block.

Properties

Property Type Default Effect
angle angle (deg/rad/pi) 135deg Azimuth of the light source. 0deg = right; 90deg = top; 180deg = left; 270deg = bottom
elevation angle 45deg Light elevation (feDistantLight elevation). Lower values flatten the highlight; 90° is overhead
depth number ≥ 0 2 surfaceScale — visual depth of the bevel
strength number ≥ 0 0.8 specularConstant — brightness of the highlight
shininess number ≥ 1 20 specularExponent — sharpness of the highlight (higher = tighter)
lightColor Color white Color of the simulated light
smooth number ≥ 0 1 Pre-blur stdDeviation for softer bevel edges; 0 disables

Generated SVG Output

For EmbossFilter() {|f| f.angle = 135deg; f.depth = 3; f.strength = 1.0; }:

<filter id="pathogen-emboss-1" x="-10%" y="-10%" width="120%" height="120%">
  <feGaussianBlur in="SourceAlpha" stdDeviation="1" result="blur"/>
  <feSpecularLighting in="blur" surfaceScale="3" specularConstant="1" specularExponent="20" lighting-color="rgb(255, 255, 255)" result="spec">
    <feDistantLight azimuth="135" elevation="45"/>
  </feSpecularLighting>
  <feComposite in="spec" in2="SourceAlpha" operator="in" result="masked"/>
  <feComposite in="SourceGraphic" in2="masked" operator="arithmetic" k1="0" k2="1" k3="1" k4="0"/>
</filter>

The final feComposite arithmetic adds the masked highlight on top of SourceGraphic, so the original colors show through everywhere except where the bevel catches light.

ElevationShadowFilter

ElevationShadowFilter() produces a Material Design–style depth shadow: three stacked soft shadow layers (tight, mid, soft) tuned by a single elevation knob. The result reads as physical depth rather than the single offset shadow CSS drop-shadow() provides.

let card = ElevationShadowFilter() {|f|
  f.elevation = 6;
  f.color = oklch(20% 0.02 280);
};

define PathLayer('card') ${
  fill: white;
  filter: card;
}
layer('card').apply { roundRect(40, 60, 120, 80, 12); }

Constructor signature: ElevationShadowFilter() — no positional arguments. Configure via the trailing block.

Properties

Property Type Default Effect
elevation number 0–24 4 Depth from the surface. 0 = flat (no shadow emitted); 2 = resting card; 8+ = pronounced lift
color Color near-black Shadow color (blended with the three layer opacities)
direction angle 90deg Direction the shadow falls toward; 90deg = down (the most common)
tightness number ≥ 0 1.0 Scales the per-layer distance/blur ratios. 0.5 = tighter, crisper depth; 2.0 = wider, hazier

Layer Decomposition

elevation parameterizes three drop-shadow-equivalent layers. Offsets are projected along direction (default 90deg = positive Y):

Layer offset (× elevation × tightness) blur stdDeviation (× elevation × tightness) opacity multiplier
tight 0.3 0.5 0.30
mid 0.6 1.0 0.18
soft 1.0 2.0 0.12

Generated SVG Output

For ElevationShadowFilter() {|f| f.elevation = 6; f.color = oklch(20% 0.02 280); }:

<filter id="pathogen-elevation-shadow-1" x="-100%" y="-100%" width="300%" height="300%">
  <feGaussianBlur in="SourceAlpha" stdDeviation="3" result="b1"/>
  <feOffset in="b1" dx="0" dy="1.8" result="o1"/>
  <feFlood flood-color="#14151f" flood-opacity="0.3" result="f1"/>
  <feComposite in="f1" in2="o1" operator="in" result="s1"/>
  <feGaussianBlur in="SourceAlpha" stdDeviation="6" result="b2"/>
  <feOffset in="b2" dx="0" dy="3.6" result="o2"/>
  <feFlood flood-color="#14151f" flood-opacity="0.18" result="f2"/>
  <feComposite in="f2" in2="o2" operator="in" result="s2"/>
  <feGaussianBlur in="SourceAlpha" stdDeviation="12" result="b3"/>
  <feOffset in="b3" dx="0" dy="6" result="o3"/>
  <feFlood flood-color="#14151f" flood-opacity="0.12" result="f3"/>
  <feComposite in="f3" in2="o3" operator="in" result="s3"/>
  <feMerge>
    <feMergeNode in="s1"/>
    <feMergeNode in="s2"/>
    <feMergeNode in="s3"/>
    <feMergeNode in="SourceGraphic"/>
  </feMerge>
</filter>

The filter region expands to -100% / 300% so the softest layer's blur fits without clipping at typical elevations on typical shapes. For very small painted regions (e.g., 20px on a 200px viewport) combined with high elevation values, the soft layer's blur radius can still exceed the filter region; the shadow will appear clipped at the boundary. Either reduce elevation or render the shape into a larger painted region.

When elevation = 0, the filter is emitted but the three shadow layers are suppressed — the output is just SourceGraphic.

InnerShadowFilter

InnerShadowFilter() produces an inset shadow — the capability CSS drop-shadow() cannot express. Use it for pressed/recessed UI elements, embossed text wells, or carved-look graphics.

let press = InnerShadowFilter() {|f|
  f.offsetX = 0;
  f.offsetY = 3;
  f.blur = 4;
  f.color = oklch(20% 0.02 280);
  f.opacity = 0.5;
};

define PathLayer('button') ${
  fill: oklch(80% 0.06 230);
  filter: press;
}
layer('button').apply { roundRect(40, 80, 120, 40, 12); }

Constructor signature: InnerShadowFilter() — no positional arguments. Configure via the trailing block.

Properties

Property Type Default Effect
offsetX number 0 Horizontal offset (positive = right). The shadow appears on the opposite side of the offset, like light coming from that direction
offsetY number 2 Vertical offset (positive = down)
blur number ≥ 0 4 Blur stdDeviation
color Color near-black Shadow color
opacity number 0–1 0.5 Shadow strength

Generated SVG Output

For InnerShadowFilter() {|f| f.offsetX = 0; f.offsetY = 3; f.blur = 4; f.color = oklch(20% 0.02 280); f.opacity = 0.5; }:

<filter id="pathogen-inner-shadow-1" x="-10%" y="-10%" width="120%" height="120%">
  <feGaussianBlur in="SourceAlpha" stdDeviation="4" result="blur"/>
  <feOffset in="blur" dx="0" dy="3" result="offset"/>
  <feComposite in="SourceAlpha" in2="offset" operator="out" result="inverted"/>
  <feFlood flood-color="#14151f" flood-opacity="0.5" result="flood"/>
  <feComposite in="flood" in2="inverted" operator="in" result="innerShadow"/>
  <feComposite in="innerShadow" in2="SourceAlpha" operator="in" result="clipped"/>
  <feMerge>
    <feMergeNode in="SourceGraphic"/>
    <feMergeNode in="clipped"/>
  </feMerge>
</filter>

The final feComposite SourceAlpha in step clips the shadow to the original silhouette so it doesn't bleed past the shape's edge. The filter region stays at -10% / 120% since the shadow is bounded by the source.

PixelateFilter

PixelateFilter() produces a mosaic / pixelation effect by sampling tiny points across the painted region and dilating each sample into a square block. Useful for retro pixel-art looks, blurred-out faces, and low-res rendering effects.

// Positional form (canonical):
let pix = PixelateFilter(12, 12, 6);

define PathLayer('portrait') ${
  fill: oklch(70% 0.18 30);
  filter: pix;
}
layer('portrait').apply { circle(100, 100, 60); }

Constructor signature: PixelateFilter(width, height, radius) — three positional numbers, or no arguments with a trailing block setting the same three properties:

// Block form (consistent with the other filter constructors):
let pix = PixelateFilter() {|f|
  f.width = 12;
  f.height = 12;
  f.radius = 6;
};

Mixing the two forms (positional arguments and a trailing block) is an error.

Properties

Property Type Default Effect
width number > 0 10 Horizontal stride between sampled pixels (= horizontal block size in the output)
height number > 0 10 Vertical stride between sampled pixels
radius number > 0 5 Dilation radius. radius = width / 2 produces blocks that just touch; larger values cause overlap, smaller leaves gaps

Generated SVG Output

For PixelateFilter(12, 12, 6):

<filter id="pathogen-pixelate-1" x="0%" y="0%" width="100%" height="100%" filterUnits="userSpaceOnUse">
  <feFlood x="3" y="3" width="2" height="2" flood-color="#000"/>
  <feComposite width="12" height="12"/>
  <feTile result="a"/>
  <feComposite in="SourceGraphic" in2="a" operator="in"/>
  <feMorphology operator="dilate" radius="6"/>
</filter>

The 2×2 flood positioned at (radius/2, radius/2) is the sample-positioning fragment. feComposite widens it to one tile cell (width × height), feTile repeats it across the filter region, the second feComposite in keeps only the source pixels that land inside the tiled samples, and feMorphology dilate expands each kept sample into a block.

The filter uses filterUnits="userSpaceOnUse" (instead of the default objectBoundingBox) so the literal pixel coordinates on feFlood / feComposite / feMorphology are interpreted as user-space distances rather than fractions of the source's bounding box. The region is 0% / 100% of the viewport — userSpaceOnUse makes % relative to the SVG viewport.

BlendMode

BlendMode is a regular built-in enum — usable anywhere a CSS blend-mode keyword is expected.

Member CSS keyword
BlendMode.Normal normal
BlendMode.Multiply multiply
BlendMode.Screen screen
BlendMode.Overlay overlay
BlendMode.ColorBurn color-burn
BlendMode.ColorDodge color-dodge
BlendMode.HardLight hard-light
BlendMode.SoftLight soft-light
BlendMode.Darken darken
BlendMode.Lighten lighten
BlendMode.Difference difference
BlendMode.Exclusion exclusion
let custom = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Paper;
  f.blend = BlendMode.SoftLight;
};

GlowMode

GlowMode selects the kind of glow a GlowFilter produces.

Member String value Effect
GlowMode.Outer outer Glow halo extends outward from the painted silhouette
GlowMode.Inner inner Glow rides along the inside edge of the painted silhouette
let halo = GlowFilter() {|f|
  f.mode = GlowMode.Outer;
  f.color = oklch(85% 0.20 60);
  f.radius = 10;
};

let edge = GlowFilter() {|f|
  f.mode = GlowMode.Inner;
  f.color = white;
  f.radius = 4;
};

Using a Filter in a Style Block

Reference a filter the same way you reference a gradient or marker — by assignment to the filter property:

let grain = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; };

define PathLayer('disc') ${
  fill: oklch(80% 0.15 50);
  filter: grain;            // → filter="url(#auto-id)"
}

The filter style property is auto-wrapping: when assigned a NoiseFilter value, the style-block evaluator converts it to url(#id) automatically. You can also reference it explicitly via filter: url(#${grain.id}) (using the .id read), or use the raw id literal if you happen to know it.

Layering with Native CSS Filters

A single filter: declaration accepts either a NoiseFilter value or a chain of native CSS filter functions like blur(2px) brightness(1.2) — not both at once. To combine custom and native filters, nest the layer in a GroupLayer:

let grain = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; };

let inner = PathLayer('inner') ${
  fill: hotpink;
  filter: grain;
};
layer('inner').apply { circle(100, 100, 60); }

let halo = GroupLayer('halo') ${ filter: blur(2px); };
halo.append(inner);

The grain renders on inner; the blur applies to the wrapping group.

Pairing with Gradients

Custom filters compose cleanly with every gradient kind. The filter applies to the layer's painted result, so a grainy gradient is just a layer with a gradient fill and a NoiseFilter filter. The Gradient style preset is tuned for this case — its primitive chain pumps contrast before blending so the grain reads through saturated gradient stops without looking muddy.

LinearGradient

let sky = LinearGradient('sky', 0, 0, 1, 1) {|g|
  g.stop(0, oklch(70% 0.20 70));
  g.stop(0.5, oklch(55% 0.22 30));
  g.stop(1, oklch(30% 0.18 280));
};

let grainy = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Gradient;
  f.amount = 0.6;
};

define PathLayer('panel') ${ fill: sky; filter: grainy; }
layer('panel').apply { rect(0, 0, 200, 200); }

RadialGradient

let glow = RadialGradient('glow', 0.5, 0.5, 0.5) {|g|
  g.stop(0, oklch(92% 0.18 80));
  g.stop(0.5, oklch(60% 0.20 40));
  g.stop(1, oklch(20% 0.05 280));
};

let grainy = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Gradient;
  f.amount = 0.55;
};

define PathLayer('orb') ${ fill: glow; filter: grainy; }
layer('orb').apply { rect(0, 0, 200, 200); }

ConicGradient

let wheel = ConicGradient('wheel', 100, 100) {|g|
  g.stop(0, oklch(70% 0.20 0));
  g.stop(0.5, oklch(70% 0.20 180));
  g.stop(1, oklch(70% 0.20 360));
};

let grainy = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Gradient;
  f.amount = 0.6;
  f.contrast = 1.4;
};

define PathLayer('wheel') ${ fill: wheel; filter: grainy; }
layer('wheel').apply { circle(100, 100, 90); }

Grain, Paper, Speckle, and Static work with gradient fills too — they just bias toward different visual outcomes. Mix and match freely.

Mesh, freeform, and topographical gradients are also supported; the noise filter rides on top of the rasterized gradient output produced by the playground or --render-gpu. See gradients.md for the full list of gradient kinds.

Generated SVG Output

Each NoiseFilter compiles into a <filter> element in <defs> whose primitive chain is selected by the chosen style. The chain shape is the same across all styles; per-style differences flow through the preset defaults table above.

For the default Grain filter on a path:

let grain = NoiseFilter() {|f| f.style = NoiseFilterStyle.Grain; };

define PathLayer('disc') ${ fill: oklch(70% 0.18 30); filter: grain; }
layer('disc').apply { circle(100, 100, 80); }

The output SVG contains (verbatim from pathogen-lang --output-svg-file):

<defs>
  <filter id="pathogen-noise-1" x="-10%" y="-10%" width="120%" height="120%">
    <feTurbulence type="fractalNoise" baseFrequency="5" numOctaves="6" seed="53252" result="turb"/>
    <feComposite in="turb" in2="SourceAlpha" operator="in" result="masked"/>
    <feColorMatrix in="masked" type="luminanceToAlpha" result="mono"/>
    <feComponentTransfer in="mono" result="noise">
      <feFuncA type="linear" slope="0.4"/>
    </feComponentTransfer>
    <feBlend in="SourceGraphic" in2="noise" mode="color-burn"/>
  </filter>
</defs>
<path d="M 20 100 a 80 80 0 1 1 160 0 a 80 80 0 1 1 -160 0" fill="oklch(70% 0.18 30)" filter="url(#pathogen-noise-1)"/>

The seed="53252" is the deterministic hash of pathogen-noise-1 — it is exactly what the compiler emits for this source; it does not need to be assigned by hand. See Seed Stability for how to lock the seed across edits.

The filter region (x="-10%" y="-10%" width="120%" height="120%") extends 10% beyond the bounding box so grain reads cleanly along strokes and edges.

Setting contrast to any value other than 1 inserts an feComponentTransfer "pump" between feTurbulence and feComposite, multiplying each RGB channel around the 0.5 midpoint to sharpen the noise before it blends.

Setting monochrome = false removes the feColorMatrix luminanceToAlpha step, leaving color variance from the turbulence in the final blend.

Browser Caveats

feTurbulence is a native browser primitive, so the visual character of each preset reads identically across Chromium, Firefox, and Safari — grain still looks like grain, static still looks like static. Pixel-level diffs between browsers will not match (Safari smooths fractal noise slightly differently than Chromium and Firefox), but the design intent is preserved. If your design depends on exact pixel reproducibility across browsers, prerender the noisy region as a raster.

numOctaves > 5 and very small baseFrequency values can be expensive to render, especially over large surfaces. The presets are tuned to stay under that threshold; if you raise octaves above 8, expect a noticeable cost on lower-end devices.

Recipes

Filmic portrait grain

Subtle filmic texture that respects the source colors — pair with photo-illustrative artwork.

let grain = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Grain;
  f.amount = 0.4;
};

Heavy paper texture

Pronounced multiplicative texture for posters or bookplate-style art. The non-default amount = 0.8 is what makes this read as heavy rather than subtle; stitch = true keeps the texture seamless across large background rectangles.

let paper = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Paper;
  f.amount = 0.8;
  f.stitch = true;
};

Risograph speckle

Coarser, more pronounced flecks than the Speckle default — the override of octaves = 3 adds a second frequency layer that gives the speckles a printed-on-cheap-paper feel.

let riso = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Speckle;
  f.amount = 0.85;
  f.octaves = 3;
};

Subtle TV static

Dialed-down static that reads as an atmospheric overlay rather than full glitch.

let snow = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Static;
  f.amount = 0.3;
  f.blend = BlendMode.SoftLight;
};

High-contrast grainy gradient

Aggressively pumped grain over a saturated gradient — contrast = 2.4 overshoots the Gradient default of 1.7 to push the noise toward stark light/dark flecks.

let grainy = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Gradient;
  f.amount = 0.7;
  f.contrast = 2.4;
};

Warm outer glow

A soft warm halo for icons or featured shapes. The radius does most of the work; opacity = 0.6 keeps the halo readable without overpowering the source.

let halo = GlowFilter() {|f|
  f.mode = GlowMode.Outer;
  f.color = oklch(82% 0.22 60);
  f.radius = 10;
  f.opacity = 0.6;
};

Inner edge light

A subtle inner glow that traces the inside edge — useful for letterforms, badges, and pressed-glass effects.

let edge = GlowFilter() {|f|
  f.mode = GlowMode.Inner;
  f.color = white;
  f.radius = 3;
  f.spread = 1;
  f.opacity = 0.7;
};

Soft emboss

A gentle bevel that catches light from the top-left. Lower depth and strength keep the highlight subtle for fine work.

let soft = EmbossFilter() {|f|
  f.angle = 135deg;
  f.depth = 2;
  f.strength = 0.6;
  f.smooth = 2;
};

Material card shadow

A resting-card depth shadow at elevation = 4 — pairs with rounded rectangles and surface cards.

let card = ElevationShadowFilter() {|f|
  f.elevation = 4;
  f.color = oklch(20% 0.02 280);
};

Pressed button

An inset shadow that makes a button look pressed into the surface. Slight offsetY simulates light coming from above.

let press = InnerShadowFilter() {|f|
  f.offsetX = 0;
  f.offsetY = 3;
  f.blur = 4;
  f.color = oklch(20% 0.02 280);
  f.opacity = 0.45;
};

Chunky pixelation

A coarse 16×16 pixel block effect — useful for retro looks or anonymizing portraits.

let pix = PixelateFilter(16, 16, 8);

Error Handling

Error Cause
NoiseFilter() takes no positional arguments — configure via the trailing block Calling NoiseFilter(...) with any arguments
Invalid value '<x>' for NoiseFilter.style. Valid values: grain, paper, speckle, static, gradient Assigning a non-enum string to f.style
NoiseFilter.style must be a NoiseFilterStyle enum value Assigning a non-string to f.style
NoiseFilter.scale must be a finite positive number f.scale = 0, negative, Infinity, or NaN
NoiseFilter.scale must be a positive number or one of 'fine' | 'medium' | 'coarse' Assigning an unrecognized string or invalid type to f.scale (use the NoiseFilterScale enum to avoid typos)
NoiseFilter.octaves must be an integer between 1 and 10 Non-integer, out of range, or wrong type assignment
NoiseFilter.amount must be a number between 0 and 1 Out-of-range, Infinity, or NaN
NoiseFilter.monochrome must be a boolean Assigning a non-boolean value
NoiseFilter.seed must be a finite number f.seed = Infinity, NaN, or non-number
Invalid value '<x>' for NoiseFilter.blend. Valid values: normal, multiply, screen, ... Assigning an unrecognized blend mode
NoiseFilter.blend must be a BlendMode enum value Assigning a non-string to f.blend
NoiseFilter.contrast must be a finite non-negative number Negative, Infinity, or NaN
NoiseFilter.stitch must be a boolean Assigning a non-boolean value
Cannot assign to NoiseFilter property '<x>' Assigning to an unrecognized property name
Property '<x>' does not exist on NoiseFilter Reading an unrecognized property
GlowFilter() takes no positional arguments — configure via the trailing block Calling GlowFilter(...) with any arguments
GlowFilter.mode must be a GlowMode enum value Assigning a non-string to f.mode
Invalid value '<x>' for GlowFilter.mode. Valid values: outer, inner Assigning an unrecognized string to f.mode
GlowFilter.color must be a Color value Assigning a non-Color value to f.color
GlowFilter.radius must be a finite non-negative number f.radius = -1, Infinity, or NaN
GlowFilter.spread must be a finite non-negative number f.spread = -1, Infinity, or NaN
GlowFilter.opacity must be a number between 0 and 1 Out-of-range or wrong type
EmbossFilter() takes no positional arguments — configure via the trailing block Calling EmbossFilter(...) with any arguments
EmbossFilter.angle must be a finite number (with angle unit) Non-number, Infinity, or NaN on f.angle
EmbossFilter.elevation must be a finite number (with angle unit) Non-number, Infinity, or NaN on f.elevation
EmbossFilter.depth must be a finite non-negative number Negative, Infinity, or NaN
EmbossFilter.strength must be a finite non-negative number Negative, Infinity, or NaN
EmbossFilter.shininess must be a finite number >= 1 Below 1, Infinity, or NaN
EmbossFilter.lightColor must be a Color value Assigning a non-Color value
EmbossFilter.smooth must be a finite non-negative number Negative, Infinity, or NaN
ElevationShadowFilter() takes no positional arguments — configure via the trailing block Calling with any arguments
ElevationShadowFilter.elevation must be a finite number between 0 and 24 Out-of-range, Infinity, or NaN
ElevationShadowFilter.color must be a Color value Assigning a non-Color value
ElevationShadowFilter.direction must be a finite number (with angle unit) Non-number, Infinity, or NaN
ElevationShadowFilter.tightness must be a finite non-negative number Negative, Infinity, or NaN
InnerShadowFilter() takes no positional arguments — configure via the trailing block Calling with any arguments
InnerShadowFilter.offsetX must be a finite number Infinity, NaN, or wrong type
InnerShadowFilter.offsetY must be a finite number Infinity, NaN, or wrong type
InnerShadowFilter.blur must be a finite non-negative number Negative, Infinity, or NaN
InnerShadowFilter.color must be a Color value Assigning a non-Color value
InnerShadowFilter.opacity must be a number between 0 and 1 Out-of-range or wrong type
PixelateFilter() expects 0 or 3 arguments (width, height, radius) Calling with 1, 2, or 4+ args
PixelateFilter() cannot combine positional arguments with a trailing block Mixing positional and block forms
PixelateFilter() arguments must be finite positive numbers Any of width, height, radius is non-number, non-positive, Infinity, or NaN
PixelateFilter.width must be a finite positive number Wrong type or non-positive on f.width
PixelateFilter.height must be a finite positive number Wrong type or non-positive on f.height
PixelateFilter.radius must be a finite positive number Wrong type or non-positive on f.radius
Cannot assign to GlowFilter property '<x>' Assigning to an unrecognized property name on a GlowFilter
Cannot assign to EmbossFilter property '<x>' Same, EmbossFilter
Cannot assign to ElevationShadowFilter property '<x>' Same, ElevationShadowFilter
Cannot assign to InnerShadowFilter property '<x>' Same, InnerShadowFilter
Cannot assign to PixelateFilter property '<x>' Same, PixelateFilter
Property '<x>' does not exist on GlowFilter Reading an unrecognized property on a GlowFilter
Property '<x>' does not exist on EmbossFilter Same, EmbossFilter
Property '<x>' does not exist on ElevationShadowFilter Same, ElevationShadowFilter
Property '<x>' does not exist on InnerShadowFilter Same, InnerShadowFilter
Property '<x>' does not exist on PixelateFilter Same, PixelateFilter

See Also

  • Gradients — pair NoiseFilter with linear, radial, conic, mesh, freeform, or topo gradients
  • LayersGroupLayer composition for stacking custom and native CSS filters
  • Markers — another defs-producing constructor following the same trailing-block convention
  • Syntax — style blocks, trailing blocks, and template-literal interpolation

Grid

A Grid is a fixed-shape, mutable, two-dimensional container of values that maps cells to canvas coordinates. It removes the need to hand-build nested arrays, exposes spatial helpers like getPoint(row, col), and supports both nearest-cell and bilinear sampling at arbitrary canvas positions — the primitives you need for flow fields, heatmaps, mesh sampling, and look-up tables.

Pathogen arrays throw on out-of-bounds access; Grid gives you 'clamp', 'wrap', and 'null' modes for spatial code where reading outside the defined region is expected — particle traces, edge-sampling kernels, toroidal flow fields.

Grids are values, not SVG elements. They do not produce <defs>; they hold data that other layers consume.

Not to be confused with the stdlib squareGrid(), triangleGrid(), and hexagonGrid() functions — those return SVG path data for visual lattices (lines, dots, shapes). Grid() here is a data container for values mapped to canvas coordinates.

Creating a Grid

Use the Grid() constructor with a row count, a column count, and an options object. A trailing block {|grid| ... } binds the newly-created grid so you can populate it:

let field = Grid(20, 20, { xDim: 10, yDim: 10 }) {|g|
  g.fill {|row, col, center|
    return calc(sin(row / g.rows) + cos(col * 3 / g.cols));
  };
};

After construction, field is a 20×20 grid spanning a 200×200 region (cols * xDim × rows * yDim), with each cell holding the value the fill block returned.

Constructor signature: Grid(rows, cols, options)rows and cols are positive integers, options is an object literal. All keys in options are optional.

Argument order is rows first, then cols (matching matrix / image conventions). For a 200×200 viewBox split into 10-unit cells you write Grid(20, 20, { xDim: 10, yDim: 10 }). Cell access is the same: grid.get(row, col), grid.getPoint(row, col).

The trailing block {|g| ... } runs once at construction. Inside the block you can call g.set(r, c, v), g.fill { ... }, or anything else that builds the cells. For uniform grids you can skip the block entirely by passing defaultValue in the options.

Constructor options

Key Type Default Purpose
xDim number 1 Cell width in canvas units. Total grid width is cols * xDim.
yDim number 1 Cell height in canvas units. Total grid height is rows * yDim.
origin Point Point(0, 0) Top-left corner of the grid in canvas space. Cell (r, c) center is at origin.x + (c+0.5)*xDim, origin.y + (r+0.5)*yDim.
defaultValue any null Initial value for every cell. Lets you skip an init block when all cells start equal.
outOfBounds string 'clamp' Sampling behavior when (x, y) falls outside the grid: 'clamp' (use the nearest edge cell), 'wrap' (toroidal — wrap around), or 'null' (return null).
interpolation string 'nearest' Default mode for .sample(x, y): 'nearest' or 'bilinear'. You can always call .sampleBilinear(x, y) explicitly.

outOfBounds: 'wrap' is the common choice for flow fields — it makes the field seamless when a particle drifts off one edge and reappears on the other.

Members

Property Type Description
rows number Row count.
cols number Column count.
xDim number Cell width.
yDim number Cell height.
origin Point Grid top-left in canvas space.
width number Total spatial width: cols * xDim.
height number Total spatial height: rows * yDim.

Cell access

get(row, col)

Returns the value stored at (row, col). Bounds-checked — throws if row or col is out of range.

let v = field.get(3, 7);

set(row, col, value)

Writes value at (row, col). Returns the grid itself so calls can be chained. Bounds-checked.

field.set(0, 0, 1.5);
field.set(0, 1, 2.0).set(0, 2, 2.5);

getPoint(row, col)

Returns the cell's center as a Point in canvas space:

let p = field.getPoint(3, 7);   // Point at canvas coords (75, 35) given xDim/yDim of 10

This is the same vocabulary used by MeshGradient — once you know it for one, you know it for both.

getRow(row) and getCol(col)

Return an array of the row's or column's cell values. Useful for sweeps, reductions, or rendering one row at a time.

cells()

Returns a flat row-major array of every cell value. Useful for reductions:

let total = field.cells().reduce(0) {|acc, v| return calc(acc + v); };

Iteration

fill {|row, col, center| ... }

Mutates every cell using the block's return value. This is the declarative replacement for nested init loops:

field.fill {|row, col, center|
  return calc(sin(row / field.rows) + cos(col * 3 / field.cols));
};

The block receives the cell's row, col, and center point. fill mutates in place and returns the grid for chaining.

forEach {|cell, row, col, center| ... }

Iterates every cell in row-major order for side effects. The standard way to draw something at each cell:

field.forEach {|angle, row, col, center|
  arrowPB.rotateAtVertexIndex(0, angle).drawTo(center.x, center.y);
};

map {|cell, row, col, center| ... }

Returns a new grid with the same rows/cols/xDim/yDim/origin but with every cell replaced by the block's return value. The original grid is unchanged. Use this when you want a derived grid (e.g., the curl of a velocity field) without losing the original.

Sampling at arbitrary positions

A grid only stores values at discrete cell centers. Sampling answers the question "what value would this grid have at canvas position (x, y)?" where (x, y) rarely lines up with a cell center.

sampleNearest(x, y)

Snaps (x, y) to the nearest cell and returns that cell's value. Fast but produces visibly stepped transitions between cells — fine for low-resolution decoration, not great for smooth particle integration.

sampleBilinear(x, y)

Blends the four surrounding cells weighted by (x, y)'s position between them. Requires numeric cell values. Produces a smooth, continuous field — see the primer below.

sample(x, y)

Dispatches to sampleNearest or sampleBilinear depending on the grid's interpolation option. Useful when you want the call site to be agnostic about which mode the grid was configured with.

Out-of-bounds behavior for all three is controlled by the grid's outOfBounds option.

Example: a flow-field arrow grid

A flow field is a 2D grid where each cell stores a direction; visualizing it draws an arrow at each cell, rotated by that direction.

Two cell representations for flow fields, choose by use case:

  • Storing scalar angles (this section) — fine when you only render at cell centers via forEach. Simpler to write and reason about.
  • Storing unit vectors Point(cos(a), sin(a)) (see the bilinear-sampling primer below) — required if you'll sample between cells (e.g., to trace a particle through the field via sampleBilinear). Raw-angle bilinear interpolation produces wrong directions at every wrap-around; vector-component interpolation is the standard fix.
define ViewBox(0, 0, 200, 200);

let arrowMarker = Marker('arrowhead', 10, 10) {|m|
  m.append(@{ m 0 0 l 10 5 l -10 5 z }, ${ fill: context-stroke; });
};

let arrowPB = @{ m 0 0 m -3 0 h 6 };

let field = Grid(20, 20, { xDim: 10, yDim: 10, outOfBounds: 'wrap' }) {|g|
  g.fill {|row, col, center|
    return calc(sin(row / g.rows) + cos(col * 3 / g.cols));
  };
};

define PathLayer('flow-vectors') ${
  stroke-width: 0.2;
  stroke: Color('#0c0');
  marker-end: arrowMarker;
}

layer('flow-vectors').apply {
  field.forEach {|angle, row, col, center|
    arrowPB.rotateAtVertexIndex(0, angle).drawTo(center.x, center.y);
  };
}

The grid's xDim/yDim does all the cell-center arithmetic, so changing the resolution to 40×40 is a one-number edit.

Bilinear sampling — what it is and when to use it

Your grid stores values at cell centers. For a 20×20 grid, that's 400 known values. When you need to read at a canvas position between cells — for example, when tracing a particle through a flow field — you have two choices:

  • Nearest-cell snaps to the closest cell. Cheap, but the value jumps abruptly at cell boundaries, so a traced particle zig-zags visibly between cells.
  • Bilinear blends the four surrounding cells weighted by how close (x, y) is to each. The field becomes smooth and continuous.

The math

The pseudocode below is what sampleBilinear does internally; you don't write any of it yourself.

Given canvas position (x, y), convert to fractional grid coordinates (cell centers are at integer values):

fc = (x - origin.x) / xDim - 0.5
fr = (y - origin.y) / yDim - 0.5
c0 = floor(fc),  c1 = c0 + 1
r0 = floor(fr),  r1 = r0 + 1
fx = fc - c0     // in [0, 1]
fy = fr - r0

Read the four surrounding cells and lerp twice horizontally, then once vertically:

v00 = cell[r0][c0]
v01 = cell[r0][c1]
v10 = cell[r1][c0]
v11 = cell[r1][c1]

top    = v00 * (1 - fx) + v01 * fx
bottom = v10 * (1 - fx) + v11 * fx
result = top * (1 - fy) + bottom * fy

Three linear interpolations, hence "bi-linear." Out-of-bounds reads (when r0 < 0, c1 >= cols, etc.) are resolved by the grid's outOfBounds option before the lerps run.

The angle-wraparound catch

If your cells store raw angles (radians or degrees) you cannot bilinearly interpolate them directly. The angles 0.01 and 2π - 0.01 are visually nearly the same direction, but their linear average is π — the opposite direction. Bilinear on raw angles produces nonsense at every wrap-around.

The clean fix is to store unit vectors instead. Each cell holds a Point(cos(angle), sin(angle)). Bilinearly interpolate x and y separately, then take atan2(y, x) to recover a smoothed angle:

let field = Grid(20, 20, { xDim: 10, yDim: 10, outOfBounds: 'wrap' }) {|g|
  g.fill {|row, col, center|
    let a = calc(sin(row / g.rows) + cos(col * 3 / g.cols));
    return Point(cos(a), sin(a));
  };
};

// Later, when sampling:
let v = field.sampleBilinear(particle.x, particle.y);
let smoothedAngle = atan2(v.y, v.x);

Bilinear interpolation of unit vectors does shrink the result slightly (a sampled point lying between two opposite-pointing unit vectors will have length near zero), but for direction extraction via atan2 the magnitude is irrelevant. This is the standard approach in generative-art flow-field codebases.

See also

  • Markers — uses the same trailing-block construction pattern.
  • Path BlocksrotateAtVertexIndex and drawTo are the natural way to render arrows at each cell.
  • GradientsMeshGradient interpolates colors across an SVG patch; Grid stores arbitrary values your code reads back via get, sample, etc. The vocabulary overlaps (getPoint, getRow, getCol) but the runtime roles are distinct.
  • Stdlib squareGrid/triangleGrid/hexagonGrid — produce SVG path data for visual lattices; not data containers.

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.

Use the spread operator (...) to expand an existing object's properties into a new object:

let base = { x: 10, y: 20 };
let extended = { ...base, z: 30 };      // { x: 10, y: 20, z: 30 }
let override = { ...base, x: 99 };      // { x: 99, y: 20 }

Spread can be mixed with regular properties and used multiple times:

let a = { x: 1 };
let b = { y: 2 };
let merged = { ...a, ...b, z: 3 };      // { x: 1, y: 2, z: 3 }

Later properties override earlier ones (same as the << merge operator):

let defaults = { stroke: 'black', width: 2 };
let custom = { ...defaults, width: 4 };  // { stroke: 'black', width: 4 }

Keys can also 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

Destructuring

Extract object properties into individual variables with destructuring in let declarations:

let point = { x: 50, y: 80 };
let { x, y } = point;
log(x);  // 50
log(y);  // 80

If a key doesn't exist, the variable is set to null:

let { x, z } = { x: 1, y: 2 };
// x is 1, z is null

Rename properties with key: localName syntax:

let point3d = { x: 1, y: 2, z: 3 };
let { z: depth } = point3d;
log(depth);  // 3

Use the rest pattern (...name) to collect remaining properties into a new object:

let config = { x: 1, y: 2, z: 3, w: 4 };
let { x, ...rest } = config;
// x is 1, rest is { y: 2, z: 3, w: 4 }

The rest pattern must be the last binding in the destructuring pattern.

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 pathogen-lang CLI compiles extended SVG path syntax into standard SVG path strings or complete SVG files.

Installation

npm install -g pathogen-lang

Or use with npx:

npx pathogen-lang [options]

Basic Usage

Compile a File

pathogen-lang input.svgx

Or with the explicit flag:

pathogen-lang --src=input.svgx

Compile Inline Code

pathogen-lang -e 'circle(100, 100, 50)'

Read from Stdin

echo 'let x = 50; circle(x, x, 25)' | pathogen-lang -
cat myfile.svgx | pathogen-lang -

Output Options

Output Path Data to File

pathogen-lang --src=input.svgx -o output.txt
pathogen-lang --src=input.svgx --output output.txt

Output as Complete SVG File

Generate a complete SVG file with the path embedded:

pathogen-lang --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

pathogen-lang -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:

pathogen-lang -e 'log("hello"); circle(50, 50, 25)' --print-logs > output.txt

Write structured JSON

pathogen-lang --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:

pathogen-lang --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

pathogen-lang -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

pathogen-lang -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:

pathogen-lang -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:

pathogen-lang -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

pathogen-lang --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

ViewBox precedence: if the source program contains a define ViewBox statement, the source value wins and the --viewBox/--width/--height flags are ignored. The flags apply only when the source has no define ViewBox.

Examples

Red circle with no fill:

pathogen-lang -e 'circle(100, 100, 50)' \
  --output-svg-file=circle.svg \
  --stroke=red \
  --stroke-width=3

Blue filled polygon:

pathogen-lang -e 'polygon(100, 100, 80, 6)' \
  --output-svg-file=hexagon.svg \
  --stroke=navy \
  --fill=lightblue \
  --stroke-width=2

Large canvas with custom viewBox:

pathogen-lang --src=complex.svgx \
  --output-svg-file=output.svg \
  --viewBox="0 0 800 600" \
  --width=800 \
  --height=600

Help and Version

pathogen-lang --help
pathogen-lang -h

pathogen-lang --version
pathogen-lang -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

pathogen-lang -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
  pathogen-lang --src="$file" --output-svg-file="${file%.svgx}.svg"
done

Use in a Build Script

{
  "scripts": {
    "build:icons": "pathogen-lang --src=src/icons.svgx --output-svg-file=dist/icons.svg"
  }
}

Examples

Practical examples showing how to use pathogen-lang 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

Security & SVG Sanitization

Pathogen produces SVG that is meant to be safe to embed inline in a host page or serve as image/svg+xml from a top-level navigation, even when the source .pathogen was authored by an untrusted party. This page documents the contract — what the compiler will accept, what it will reject, and what guarantees the output gives downstream consumers.

The contract exists because SVG renderers have a long history of being used to exfiltrate data, restyle host pages, and execute scripts. The article On Scratch SVG Sanitization catalogs eleven classes of attacks against the Scratch project's SVG pipeline, several of which apply to any system that emits user-influenced SVG. Pathogen's contract closes those attack classes at the producer.

Compiler contract

Compiled SVG output is safe to embed inline OR serve as image/svg+xml from arbitrary .pathogen source.

This means the SVG that comes out of compile() → buildSvgTree() → toSvgString() is guaranteed to contain:

  • No <script>, <iframe>, <foreignObject>, <a>, <animate>, <animateTransform>, <animateMotion>, <set>, <discard>, <handler>, or <listener> elements.
  • No on* event-handler attributes on any element.
  • No href or xlink:href attribute pointing anywhere except a local fragment (#name) or a data:image/... URI. No http:, https:, javascript:, data:text/html, or protocol-relative URLs.
  • No CSS url(...), image-set(...), image(...), src(...), var(...), calc(...), expression(...), attr(...), @import, or CSS escape sequences (\xx) in any style value.
  • No CSS comments inside style values.
  • Identifiers (layer names, mask/clipPath/gradient/pattern/marker IDs, CSSVar() names) restricted to the CSS-ident grammar: --?[A-Za-z_][A-Za-z0-9_-]*.

Anything that would violate this contract is rejected at evaluate-time with a Pathogen error citing the source line and column.

What's allowed in style { … } blocks

Pathogen uses a strict allow-list for style values. Allowed value forms are:

  • Numbers, with optional unit: px, em, rem, %, deg, rad, turn, s, ms. Example: stroke-width: 2, font-size: 1.25rem, transform: rotate(45deg).
  • CSS hex colors: #rgb, #rgba, #rrggbb, #rrggbbaa. Example: fill: "#e63946".
  • CSS keyword identifiers (matching [A-Za-z_][A-Za-z0-9_-]*): none, currentColor, bold, inherit, initial, etc.
  • CSS color functions from a fixed allow-list: oklch(...), oklab(...), lch(...), lab(...), rgb(...), rgba(...), hsl(...), hsla(...), color(...). Example: fill: oklch(0.7 0.15 240).
  • Local fragment refs (#ident) on properties that take URLs: mask, clip-path, filter, fill, stroke. Example: mask: "#myMask".
  • Quoted strings on string-typed properties: font-family, content. Example: font-family: "Inter, sans-serif".
  • Pathogen CSSVar() values (auto-converted to var(...) during emission). Use this instead of writing var() literally — see below.

What's rejected:

# Rejected: url() with any argument
define PathLayer('a') ${ background-image: "url(https://evil.example/log)"; }

# Rejected: var() — use CSSVar() instead
define PathLayer('a') ${ fill: "var(--brand)"; }

# Rejected: calc() in style values
define PathLayer('a') ${ stroke-width: "calc(2px + 1em)"; }

# Rejected: image-set, image, src, expression, attr
define PathLayer('a') ${ background-image: "image-set('foo.png' 1x)"; }

# Rejected: CSS escape sequences
define PathLayer('a') ${ fill: "\\75\\72\\6c(...)"; }

# Rejected: CSS comments in values
define PathLayer('a') ${ fill: "/* comment */ red"; }

If your design needs CSS variables, use Pathogen's CSSVar() constructor — see the CSSVar docs. The compiler will emit a properly-formed var(--name, fallback) reference for you.

What's allowed as identifiers

CSSVar() names, layer names, and the id argument to Mask(), ClipPath(), Pattern(), Marker(), LinearGradient(), RadialGradient(), ConicGradient(), MeshGradient(), FreeformGradient(), and TopoGradient() must match the CSS-ident grammar:

  • CSSVar() names: --?[A-Za-z_][A-Za-z0-9_-]* and must start with --. Example: --primary, --brand-color.
  • Other identifiers: [A-Za-z_][A-Za-z0-9_-]*. Example: myMask, gradient_1.

Spaces, punctuation, quotes, braces, semicolons, and CSS escape sequences are rejected. The restriction exists because identifiers reach the SVG id attribute, the CSS @property rule, and various URL-fragment refs — anywhere a parser-mismatch could let an attacker break out into a different syntactic context.

What SVGDocumentFragment() rejects

SVGDocumentFragment("...") accepts a literal string of SVG markup and inserts it into the compiled output. Because the string is user-supplied, it is run through a sanitizer that rejects:

  • All elements except defs, g, path, circle, ellipse, line, polygon, polyline, rect, image, linearGradient, radialGradient, pattern, mask, clipPath, marker, stop, text, tspan, filter, and SVG filter primitives (feBlend, feColorMatrix, etc.).
  • All on* event-handler attributes.
  • Inline style="..." attributes and <style> blocks. Use a Pathogen style { … } block on the surrounding layer instead — that goes through the value allow-list above.
  • href / xlink:href attributes whose value is not a local fragment (#name) or a data:image/(png|jpeg|gif|webp);base64,... URI.

A malformed fragment, a blocked element, or a forbidden attribute throws a Pathogen evaluator error with line/column information.

Playground & blog rendering

When the playground or the static blog renders compiled SVG, it does so inside a sandboxed iframe with a strict Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline' data:; img-src data:; connect-src 'none'). This is defense in depth: even if the compiler produced unsafe content (it won't), the iframe sandbox prevents the SVG from affecting the host page or making outbound requests.

The VS Code preview surface uses the same CSP via the webview's cspSource.

If you embed dist/index.global.js in your own page and feed it user-supplied .pathogen source, you inherit the compiler contract automatically — the SVG produced is safe to embed inline. We still recommend you mount it inside your own iframe + CSP for layered defense.

Reporting a vulnerability

If you find a way to violate the compiler contract above (CSS injection, identifier escape, fragment sanitizer bypass, or any path to an outbound request from rendered SVG), please open a GitHub issue tagged security with a minimal reproducing .pathogen source. We'll acknowledge within a few days and ship a fix.

Publishing Workspaces

Pathogen Studio's Explore and Featured pages showcase community workspaces. Anyone can browse them; only verified-email accounts can submit, and every submission is reviewed before it appears.

Who can publish

You can submit a workspace for review once you:

  • have signed in via email OTP, and
  • have an active account in good standing.

Anonymous (signed-out) drafts and accounts that have not yet verified an email cannot submit. If your account is unable to submit, the Make this workspace public option will not appear in the new-workspace form, and the Publish workspace action in the editor menu will be hidden.

Submitting for review

  1. Open the workspace you want to submit.
  2. From the overflow menu (), choose Publish workspace.
  3. The workspace enters the review queue. Its state is now Pending review.

Pending workspaces are not visible on Explore. You can keep editing while you wait — your edits do not change what the reviewer sees, because the code is frozen at the moment of submission.

If a workspace is approved, it appears on Explore at a permanent URL under your handle. If the reviewer also chooses to feature it, the workspace appears on Featured as well.

If a workspace does not become public after review, its state returns to Not published. You may revise the workspace and submit it again — each submission is treated as a fresh review.

Editing a published workspace

Approved workspaces can be edited freely. The version shown on Explore and on your workspace detail page is the snapshot that was reviewed, not the live workspace, so visitors see a stable version that does not change as you continue editing.

If you make changes to an approved workspace, it returns automatically to the re-review queue as soon as your code differs from the approved snapshot. Your editor menu shows Pending re-review until a reviewer approves the new version. The previously approved snapshot stays on Explore until the new version is reviewed — visitors never see a half-edited workspace.

If a re-submission is not approved, the previously approved version stays public. The owner can either resubmit again or revert their edits to match the approved snapshot.

To remove a workspace from Explore, choose Unpublish workspace from the overflow menu. The workspace returns to a private draft. You can resubmit it later — it goes through review again.

Limits

  • Explore shows the 100 most recently approved workspaces. Older approvals continue to be reachable at their permanent URL but no longer appear on the Explore grid.
  • Featured is curated and is limited to 100 entries.

Workspace URLs

Approved workspaces have a permanent URL of the form /u/<handle>/<workspace-slug>. The slug is derived from the workspace name at the moment of approval and remains stable even if you rename the workspace later. If two of your workspaces resolve to the same slug, the second is suffixed with a short identifier so both remain reachable.

The workspace detail page renders the frozen approved snapshot — the code, name, description, and thumbnail captured at the moment of approval. Subsequent edits to the live workspace do not change what visitors see at this URL until the new version is approved through re-review.

A breadcrumb at the top of the detail page links back to your profile (/u/<handle>) and to the public Explore page.

Reviewer access

The review queue is gated by an internal allow-list (the ADMIN_EMAILS environment variable on the API Worker). There is no self-service path to becoming a reviewer.