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:
- Moving to position (10, 10)
- Drawing a horizontal line of length 50
- Drawing a vertical line of length 50
- Drawing a horizontal line back
- 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 elementindex(optional) — the zero-based indexarrayRef(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 valueitem(optional) — the current elementindex(optional) — the zero-based indexarrayRef(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
startto the first control point - pv2 — direction and distance from
endto 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 quadraticexitTime = 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).
type—GridPatternTypeenum value or string ('shape','dot','intersection','partial')x, y— Top-left origin of the gridwidth, height— Bounding dimensionscellSize— 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) orHexagonOrientation.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 centerradius: Arc radiusstartAngle, endAngle: Start and end angles in radiansclockwise: 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 radiusangleOfArc: 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
widthorheight— these are invalid SVG dimensions. defaultmodifier —define default ViewBox(…)is not allowed; onlyPathLayerandTextLayeracceptdefault.- 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.
Related
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 alayer().applyblock targeting a TextLayertspan()can only appear inside atext() { }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 blocks —
layer().applyblocks 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:
- Relative commands only — All path commands must be lowercase (
m,l,h,v, etc.). Uppercase (absolute) commands throw an error. - No layer definitions —
define PathLayer/TextLayeris not allowed - No layer apply blocks —
layer().apply { }is not allowed - No text statements —
text()/tspan()are not allowed - No nesting — Path blocks cannot contain other
@{ }expressions - 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 pathsubPath(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
otherargument 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/Fontson 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 elementslet,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/dyoffsets
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
@fontdirective or compile options font-familymust 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 GroupLayerfontSize(number, optional) — code font size, default 10padding(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:
- Iterates adjacent stop pairs
- Generates
ceil(steps * offsetSpan) - 1intermediate stops between each pair - Uses
mixColors()for shortest-arc hue interpolation in OKLCh space - 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 fromfromtoto'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 2π) |
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.
colsandrowsmust 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:
- Containment test: For each contour, ray-cast to determine if the pixel is inside (even-odd rule)
- Floor elevation: Highest elevation among all contours containing the pixel
- Ceiling elevation: Lowest elevation among contours NOT containing the pixel but above the floor
- Distance interpolation: Compute minimum distances to floor and ceiling boundaries, interpolate elevation
- Easing: Apply the easing function to the interpolation parameter
- 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(@{ ... }) orProjectedPath. 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 valuescontext-strokeandcontext-filldescribed 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 | MarkerPreserveAspectRatio — None, 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-startmarker-midmarker-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(#...):
maskclip-pathfiltermarker-startmarker-midmarker-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:
NoiseFilter— grain, paper, speckle, TV static, grainy gradientGlowFilter— soft outer or inner glowEmbossFilter— light-source-based embossed surfaceElevationShadowFilter— Material-style layered depth shadowInnerShadowFilter— inset shadow (no native CSS equivalent)PixelateFilter— mosaic / pixelation
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
NoiseFilterwith linear, radial, conic, mesh, freeform, or topo gradients - Layers —
GroupLayercomposition 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(), andhexagonGrid()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 viasampleBilinear). 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 Blocks —
rotateAtVertexIndexanddrawToare the natural way to render arrows at each cell. - Gradients —
MeshGradientinterpolates colors across an SVG patch;Gridstores arbitrary values your code reads back viaget,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
- Start simple: Build complex shapes from simple parts
- Use variables: Makes code readable and adjustable
- Extract functions: Reuse common patterns
- Test incrementally: Generate SVGs often to see results
- 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+xmlfrom arbitrary.pathogensource.
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
hreforxlink:hrefattribute pointing anywhere except a local fragment (#name) or adata:image/...URI. Nohttp:,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 tovar(...)during emission). Use this instead of writingvar()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 Pathogenstyle { … }block on the surrounding layer instead — that goes through the value allow-list above. href/xlink:hrefattributes whose value is not a local fragment (#name) or adata: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
- Open the workspace you want to submit.
- From the overflow menu (
⋮), choose Publish workspace. - 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.