Getting Started

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

Your First Path

Try this simple example in the playground:

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

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

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

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

This creates a rectangle by:

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

Why pathogen-lang?

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

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

With pathogen-lang, you can use variables and loops:

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

Key Features

Variables

Store and reuse values:

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

Expressions with calc()

Use math in path commands:

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

Loops

Repeat patterns easily:

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

Functions

Define reusable shapes:

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

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

Built-in Shapes

Common shapes are included:

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

Next Steps

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

Syntax Reference

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

Path Commands

All standard SVG path commands are supported:

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

Uppercase commands use absolute coordinates; lowercase use relative coordinates.

M 0 0 L 100 100 Z

Variables

Declare variables with let:

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

Use variables directly in path commands:

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

Two naming rules:

  • pi, deg, and rad are reserved. They are angle unit suffixes, and only that: let pi = …, fn deg(…) { }, or using one as a loop or function parameter is a compile error, and referencing one standalone (calc(pi)) errors with a pointer at the forms that do exist — the suffix (0.5pi, 90deg, 1.5rad) and the standard-library calls (PI(), deg(x) / rad(x)), which remain untouched.
  • Single letters that are path commands (m l h v c s q t a z, either case) can be declared, but cannot be referenced bare in path-argument position — there they always read as commands. let m = 25; L m 40 is a compile error naming this rule; L calc(m) 40 works. Prefer longer 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})`;
let word = `${n > 1 ? 'pieces' : 'piece'}`;   // any expression works,
                                              // ternaries included

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

calc() computes on values. Percent suffixes are converted at the literal (20% → 0.2), so they always produce plain numbers. Angle suffixes (deg, rad, pi) are different: an angle-suffixed literal produces an Angle value, and calc() arithmetic keeps angle-ness where it makes sense — scaling an angle by a plain number is still an angle, a ratio of two angles is a plain number. An angle is an angle wherever it flows: it survives being stored in a variable, put in an array, or passed through a function. See Angle Units.

Separately, the compiler still reads the expression as written to reject nonsense at compile time, like calc(0.25pi + 5) — see the mismatch rules under Angle Units.

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) — or apply a worker function to a callback builtin: arr.map() << f
?: Conditional (ternary): cond ? a : b — see below

Operator precedence follows standard mathematical conventions.

Conditional (Ternary) Expressions

cond ? a : b evaluates to a when the condition is truthy, b otherwise — and the branches can be any values, strings included:

let count = 2;
let label = count > 1 ? 'pieces' : 'piece';

// Works inside template interpolation…
let caption = `${count} ${count > 1 ? 'pieces' : 'piece'} total`;

// …and inside style-block values:
let hot = PathLayer('mark') ${ stroke-width: count > 1 ? 4 : 1; };

Ternaries also work inside a path argument's calc()L calc(count > 1 ? 40 : 10) 0.

The alternative to a ternary is plain reassignment — variables are mutable, and reassigning inside an if branch is the natural spelling when the choice takes more than one expression:

let tone = 'cool';
if (temperature > 30) {
  tone = 'warm';
}

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. The trailing ; is required on every declaration, including the last one before } — a declaration missing its ; is a compile error. Values are try-evaluated as expressions — if the value parses as a valid expression (like a variable reference, backtick template literal, arithmetic, a ternary, a function call, or calc()), its result is used. Otherwise the raw string is kept (e.g., rgb(...), #hex). See Variables and Interpolation in Values for dynamic values.

CSS Function Values

Style-block values are native CSS syntax, not Pathogen call syntax. This matters for CSS functions whose grammar is space-separated — the CSS filter functions (blur, brightness, contrast, drop-shadow, grayscale, hue-rotate, invert, opacity, saturate, sepia) take space-separated arguments, and commas inside them are a compile error:

// ✗ Compile error — Pathogen-style commas are not valid CSS here
filter: drop-shadow(4px, 4px, 4px, shadowColor);
// drop-shadow() uses space-separated CSS syntax:
// drop-shadow(4px 4px 4px color) — remove the commas

// ✓ Native CSS syntax — space-separated
filter: drop-shadow(4px 4px 4px shadowColor);

Filter chains are also space-separated: filter: blur(2px) brightness(1.2); — a comma between chained filter functions is likewise a compile error.

Functions whose CSS grammar genuinely uses commas keep them, exactly as in CSS: rgba(0, 0, 0, 0.5), color-mix(in oklch, red, blue), translate(10px, 20px), cubic-bezier(0.4, 0, 0.2, 1), polygon(0 0, 100% 0, 50% 100%), and font-family fallback lists.

Pathogen variables still work anywhere inside a CSS function value — drop-shadow(4px 4px 4px shadowColor) resolves shadowColor to its CSS color at compile time, and a numeric variable substitutes as a bare number (brightness(level)brightness(1.4)). Substitution is not unit-aware, and the compiler checks the result — arguments that need a unit want a template fragment instead: blur(`${softness}`px). See Argument Units below and Variables and Interpolation in Values.

Argument Units

CSS is strict about units per function, and a wrong one makes the browser drop the whole declaration silently. Pathogen checks the final value — after any variable substitution or interpolation — and fails with a fix-it message instead:

Group Functions Numeric arguments
Filter blur, drop-shadow A length — unit required (px, em, rem, pt, in, cm, vw, …). No percentages.
Filter hue-rotate An angle — unit required (deg, rad, turn).
Filter brightness, contrast, grayscale, invert, opacity, saturate, sepia A plain number or percentage. Units are an error.
Shape inset, circle, ellipse, polygon A length or percentage — one or the other is required.
Transform scale, scaleX, scaleY, scaleZ, scale3d, matrix, matrix3d Plain numbers only. Any unit, including %, is an error.
Timing cubic-bezier, steps Plain numbers only. Any unit, including %, is an error.

The table above is the complete list of checked functions. Any CSS function not listed — including rotate, translate, skew, perspective, the color functions, and path() — passes through unchecked.

filter: blur(4);              // ✗ blur() takes a length — "4" needs a unit (try 4px)
filter: blur(4px);            // ✓
filter: hue-rotate(90);       // ✗ hue-rotate() takes an angle — "90" needs a unit (try 90deg)
filter: hue-rotate(90deg);    // ✓
filter: opacity(50%);         // ✓ percentages are fine for filter amounts
transform: scale(2px);        // ✗ scale() takes plain numbers — "2px" must not have a unit
transform: scale(0.5);        // ✓

let softness = 4;
filter: blur(softness);       // ✗ substituted values are checked the same way
filter: blur(`${softness}`px); // ✓

Zero is always allowed bareblur(0) and polygon(0 0, 100% 0, 50% 100%) are valid CSS and pass unchanged.

Why Some Functions Are Unchecked

Pathogen checks a function's units only where CSS and SVG agree on the right answer.

Color functions (oklch, rgb, color-mix, …) accept numbers, percentages, and angles interchangeably depending on the channel, so there is no single rule to enforce — they are left alone.

Most transform functions are emitted into SVG's transform attribute, whose grammar takes unitless user units. transform: rotate(45) and translate(100, 200) are correct there and are exactly what Pathogen's own transform convenience properties generate, while the CSS grammar for the same functions requires units. Because the two grammars disagree, Pathogen accepts both forms and checks neither — translate(10px, 20px) is accepted too.

scale* and matrix* are the exception, which is why they appear in the table. A unit is invalid in both grammars — SVG's is scale(<number>) and matrix(<number>×6) — so there is one correct answer and Pathogen enforces it.

Functions Must Match the Property

A function also has to belong to the property it is used on — fill: rotate(45); is a compile error, since a transform function means nothing to fill:

fill: rotate(45);             // ✗ rotate() is not valid on "fill" — that property takes color functions
fill: oklch(0.7 0.15 240);    // ✓
clip-path: blur(4px);         // ✗ blur() is not valid on "clip-path" — that property takes basic shapes
clip-path: circle(50%);       // ✓

The mapping is:

Property Accepts
filter Filter functions
clip-path Basic shapes
transform Transform functions
fill, stroke, stop-color, flood-color, lighting-color Color functions
transition-timing-function, animation-timing-function Timing functions

Any property not in this table accepts any allow-listed function. Nesting is unaffected: drop-shadow(4px 4px 8px oklch(0.65 0.26 357)) is fine, because only the outermost function is matched against the property.

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.

Applying workers (<<)

<< has a second job: it applies a worker function to a callback builtin. The parentheses parameterize the call; << supplies the function that does the work. (The shape rhymes with PathLayer('name') << styles — though that one is mechanically a merge, the reading "feed the right side into the left" is the same.)

let doubler = {|v| return calc(v * 2); };
let doubled = [1, 2, 3].map() << doubler;

let cells = Grid(4, 4, { xDim: 25, yDim: 25 });
cells.fill() << {|row, col| return calc(row * 4 + col); };

let total = [1, 2, 3].reduce(0) << {|acc, v| return calc(acc + v); };

let spine = @{ l 120 0 };
let ribbon = spine.variableOffset() << {|go, pb|
  go.stop(0, 4, CurveContinuity.G1);
  go.stop(1, 12, CurveContinuity.G1);
};

The rule is structural: when the left side is one of the nine callback builtins — array .map/.filter/.reduce/.sort, Grid.fill/.forEach/.map, variableOffset/compoundVariableOffset — written without a trailing block, << provides its callback. The right side may be a lambda variable, a named fn, or a lambda literal. Anywhere else, << is the ordinary merge above.

Evaluation order: the receiver, then the parenthesized arguments (e.g. reduce's initial value), then the worker expression, then the iteration.

A few consequences of the structural rule:

  • A trailing block and a << worker are mutually exclusive. If the call already carries a literal block, << falls back to merge — so spine.variableOffset() {|go, pb| ... } << edge still means "build the offset, then concatenate the edge path block", exactly as before. (And arr.map {|v| ... } << f errors: the block completed the call, and a function is not a mergeable value.)
  • Application happens once. arr.map() << f << g applies f, and the second << then sees an array on its left — already a value, so the error says exactly that. Apply then merge is fine when the types line up: spine.compoundVariableOffset() << mk << tail builds the ribbon and concatenates tail.
  • Passing the worker inside the parentheses (map(f)) is not supported — the error message points you to <<.

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(), and by the .first and .last properties, 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

The Easing curves are also callable as stdlib functions (easeIn, easeOut, easeInOut, smoothstep) — the formulas and the enum↔function mapping are documented in the stdlib's Easing section.

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 Turn { 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. Angle values are accepted anywhere an angle goes in (.rotate(90deg, c)); angles that come out (.angleTo()) are plain numbers in radians.

.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

.first

Returns the first element, or null if the array is empty (see Null) — unlike list[0], which throws when the array is empty. The array is not modified (unlike .shift(), which removes the element it returns).

let list = [10, 20, 30];
log(list.first);   // 10

let empty = [];
log(empty.first);  // null

Note: If the first element is itself null, the result is indistinguishable from the empty-array case (the same is true of .last, .pop(), and .shift()). Check .length when the distinction matters.

.last

Returns the last element, or null if the array is empty (see Null) — unlike list[list.length - 1], which throws when the array is empty. The array is not modified (unlike .pop(), which removes the element it returns). The empty-vs-null-element ambiguity noted under .first applies here too.

let list = [10, 20, 30];
log(list.last);    // 30

let empty = [];
log(empty.last);   // null

.empty()

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

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

Methods

The four mutating methods — .push(), .pop(), .unshift(), and .shift() — throw if called while the array is being iterated (see Reference Semantics).

.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. A reusable worker applies with <<prices.map() << f; (see Applying workers).

The block receives up to three parameters:

  • item — the current element
  • index (optional) — the zero-based index
  • arrayRef (optional) — a reference to the original array (reading is fine; mutating it throws — see Reference Semantics)
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]

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

Returns a new array containing only the elements for which the trailing block returns a truthy value. Filtering is how you keep only the points inside a region, drop whitespace glyphs before layout, or cull segments below a size threshold before drawing.

null, 0, and false are falsy; non-zero numbers, true, and non-empty strings are truthy (see Null and Booleans). The original array is not modified. If no return is executed, the block produces null, which is falsy — the element is dropped. A reusable worker applies with <<nums.filter() << isPositive; (see Applying workers).

The block receives up to three parameters:

  • item — the current element
  • index (optional) — the zero-based index
  • arrayRef (optional) — a reference to the original array (reading is fine; mutating it throws — see Reference Semantics)
let nums = [4, -2, 7, 0, -5];
let positive = nums.filter {|n|
  return n > 0;
};
// positive is [4, 7]
// nums is still [4, -2, 7, 0, -5]

Use the index parameter for position-aware filtering:

let items = [10, 20, 30, 40];
let evens = items.filter {|val, i|
  return calc(i % 2) == 0;
};
// evens is [10, 30] — elements at even indexes

Use the array reference to compare against neighbors:

let arr = [1, 5, 3, 8, 2];
let rising = arr.filter {|item, idx, ref|
  if (idx == 0) { return true; }
  return item > ref[idx - 1];
};
// rising is [1, 5, 8] — elements greater than their predecessor

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

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

The block receives up to four parameters:

  • accumulator — the current accumulated value
  • item (optional) — the current element
  • index (optional) — the zero-based index
  • arrayRef (optional) — a reference to the original array (reading is fine; mutating it throws — see Reference Semantics)
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

A reusable worker applies with << — the initial value stays in the parentheses: values.reduce(0) << sumFn; (see Applying workers).

.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]]

.reverse()

Returns a new array with the elements in reverse order. The original array is not modified.

let arr = [1, 2, 3];
let rev = arr.reverse();
// rev is [3, 2, 1]
// arr is still [1, 2, 3]

.sort() / .sort {|a, b| ... }

Returns a new array with the elements sorted. Sorting is how you z-order shapes by area, order gradient stops by offset, or arrange points by angle before drawing.

Note: Unlike JavaScript, .sort() and .reverse() do not sort or reverse in place — they return new arrays and leave the original untouched. Of the array methods, .push(), .pop(), .unshift(), and .shift() mutate the array; .slice(), .map(), .filter(), .mapSlice(), .reverse(), and .sort() return copies. The mutating methods throw while the array is being iterated — see Reference Semantics for the iteration lock and why the mutate-vs-copy distinction matters when an array has more than one binding.

Called without a block, .sort() sorts in natural ascending order — numbers sort numerically, strings by character code order:

let source = [10, 2, -1];
let nums = source.sort();
// nums is [-1, 2, 10] — numeric, not lexicographic
// source is still [10, 2, -1]

let names = ["cherry", "apple", "banana"].sort();
// names is ["apple", "banana", "cherry"]

String order compares character codes, not locale rules — uppercase letters sort before lowercase, and digits before letters:

let mixed = ["apple", "Banana"].sort();
// mixed is ["Banana", "apple"] — "B" (66) precedes "a" (97)

For locale-aware or any other custom ordering, supply a comparator block.

The natural order is only defined when every element is a number, or every element is a string. Sorting anything else without a comparator — Points, Colors, null, or mixed types — is an error:

let mixed = [1, "two", Point(0, 0)];
let bad = mixed.sort();
// Error: sort() without a comparator requires all-number or all-string
// elements — use sort {|a, b| return ...; } to define the order

NaN has no defined order, so a numeric array containing NaN is also an error. An empty array sorts to an empty array. A single-element array of an unsortable type still errors — the element check does not depend on the array's size.

For custom ordering, supply a comparator as a trailing block or apply one with << (values.sort() << cmp — see Applying workers). .sort() takes no parenthesized arguments:

let points = [Point(30, 0), Point(10, 0), Point(20, 0)];
let byX = points.sort {|a, b|
  return calc(a.x - b.x);
};
// byX is [Point(10, 0), Point(20, 0), Point(30, 0)]

The comparator block receives exactly two parameters — unlike .map and .reduce, there is no index or array reference:

  • a — the first element being compared
  • b — the second element being compared

The block must return a number:

  • negative — a sorts before b
  • positive — b sorts before a
  • zero — keep the original relative order

The sort is stable: elements that compare equal keep their original relative order.

Note: The comparator must return a number — a comparison operator will not do. return a < b; can only produce two outcomes, but a comparator needs three: a first, b first, or equal. Subtract instead: return calc(a - b);

Returning anything other than a number (including NaN) raises: sort() comparator must return a number (negative = a first, positive = b first, zero = keep order) — e.g. return calc(a - b);

let descending = [3, 1, 2].sort {|a, b|
  return calc(b - a);
};
// descending is [3, 2, 1]

The comparator can read variables from the enclosing scope, but any path commands it emits are discarded — a comparator is for ordering only. The array being sorted is locked while comparators run: mutating it from inside the block is an error (see Reference Semantics).

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

Iteration Lock

Because every binding — including the arrayRef parameter that .map, .filter, and .reduce pass to their blocks — refers to the same array, an array is read-only while it is being iterated. Calling .push(), .pop(), .shift(), or .unshift() on it, or assigning to an element (arr[i] = x), from inside a .map/.filter/.reduce/.sort block or a for (item in arr) body is an error:

let nums = [1, 2, 3];
let bad = nums.filter {|n, i, ref|
  ref.push(99);  // Error!
  return n > 1;
};

Cannot call push() on an array while it is being iterated — callbacks and for-each bodies receive the array read-only. Iterate a copy with .slice(0) if you need to mutate.

Assigning to an element raises the same guidance: Cannot assign to an element of an array while it is being iterated — ...

The lock belongs to the array value, not to the syntax you wrote: it is held until every in-progress iteration of that array finishes, it applies equally to callbacks applied with << workers, and a mutation throws wherever it happens — including inside a helper fn called from the loop body. Reading is always fine — nested iteration of the same array is legal (the lock stacks) — and so is mutating a different array (building a result array inside a callback is the normal pattern). Unlike JavaScript — where map/filter capture the length up front so appended elements are silently never visited, and for...of or index loops can skip or revisit elements after a shift/unshift — Pathogen fails loudly. If you need to append while walking an array, iterate a snapshot:

let queue = [1, 2, 3];
for (item in queue.slice(0)) {
  queue.push(calc(item * 10));  // fine — the loop iterates the copy
}
// queue is [1, 2, 3, 10, 20, 30]
// The loop ran three times: appended elements belong to queue,
// not the snapshot, so they are never visited.

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)
}

The array is locked during the loop — mutating it from inside the body (points.push(...), points[i] = x) is an error. To append to an array while walking it, iterate a snapshot — for (p in list.slice(0)) — but note the loop visits only the elements the snapshot captured; anything appended during the loop is not visited (see Reference Semantics).

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.

Object destructuring works on object literals and on fixed-shape struct values such as Point, Grid, Color, and ctx.position — see Destructuring in the Objects guide:

let { x, y } = Point(20, 20);

The one-name syntax also works in the other direction: { x, y } builds an object with those variables as values — see Shorthand Properties in the Objects guide.

Angle Units

Numbers can have angle unit suffixes:

Suffix Description
45deg Degrees
1.5rad Radians
0.25pi Multiplied by π (i.e. 0.25 * π)

The three suffix names are reserved words: they exist only as suffixes, so pi, deg, and rad cannot be declared as variables or referenced standalone (see Variables). The spellings that exist: 0.5pi (an Angle), PI() (the number π), deg(x)/rad(x) (the unit converters).

An angle-suffixed literal produces an Angle value — radians on the inside, with the written unit remembered for display. An angle is an angle wherever it flows: it survives variables, arrays, function parameters, return values, and angle-preserving standard-library functions like clamp, lerp, and randomRange. In angle-accepting positions — path arguments, trig and polar functions, comparisons, loop bounds, calc() arithmetic, rotation and orient properties, gradient from/to, filter angles — an Angle reads as its radians value, so sin(45deg) and sin(rad(45)) are identical. Slots that want a plain count or dimension still reject an Angle: Grid(90deg, 4) errors with "must be a positive integer". The one place radians are not the reading is the degree-based color API — see Angle Display below.

Behavior change: Angle units used to be consumed at the literal — let t = 0.5pi; c.hueShift(t) shifted 1.57°, not 90°. Angles are now values that survive variables, so that program shifts 90°. Likewise Color(0.6, 0.15, 90deg) now stores hue 90 (was 1.5708), interpolating an Angle prints 90deg rather than a radians number, and sort() orders Angle arrays instead of erroring. Angle-preserving standard-library calls now pass the angle through too — randomRange(-0.5pi, 0.5pi) returns an Angle, not bare radians.

let quarter = 90deg;      // an Angle — prints "90deg", auto-converts for hue methods
M sin(quarter) cos(quarter)

// Only inside sin() are these equivalent — an Angle reads as radians there.
// rad(45) itself returns a plain number, not an Angle:
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 eighth = 0.25pi;   // π/4
let half = 0.5pi;      // π/2
let full = 2pi;        // 2π
M sin(eighth) cos(eighth)

Angle Members

An Angle value exposes its measure in whichever unit you need:

Member Description
.deg The angle in degrees
.rad The angle in radians
.pi The angle in multiples of π (90deg.pi is 0.5)
.turns The angle in full circles (1 = 360°)

All members return plain numbers:

let a = 90deg;
log(a.deg);    // 90
log(a.rad);    // 1.5707963267948966
log(a.pi);     // 0.5
log(a.turns);  // 0.25

Unit Re-tagging

.toDeg(), .toRad(), .toPi(), and .toTurns() return the same angle with a different display unit — the value is unchanged (it still compares equal and reads as the same radians everywhere), only how it prints changes:

let a = 90deg;
log(a.toPi());          // 0.5pi
log(a.toRad());         // 1.5707963267948966rad
log(a.toTurns());       // 0.25turns
log(a.toPi() == 0.5pi); // true — same angle, different label

turns exists only as a display unit — there is no 0.25turns literal; write calc(0.25 * 2pi) (or 90deg) and re-tag with .toTurns() when you want it displayed that way.

Where Angles come from. Only angle-suffixed literals — plus calc() arithmetic over them and angle-preserving standard-library functions fed an Angle (clamp(90deg, 0deg, 1pi) is an Angle) — produce Angle values. Standard-library functions that compute an angle return plain numbers in radians: atan2(), .angleTo(), tangent(t).angle, PolarVector.angle, and deg()/rad()/mpi(). That matters most for the degree-based color methods below, which read a bare number as degrees — write c.hueShift(deg(p.angleTo(q))), not c.hueShift(p.angleTo(q)).

Angle Arithmetic

calc() arithmetic keeps angle-ness where it makes sense:

  • Adding or subtracting angles gives an angle: calc(90deg + 0.5pi).
  • Scaling an angle by a plain number keeps the angle: calc(2 * 45deg), calc(2pi / 4), and calc(i / 9 * 2pi) are all angles.
  • A ratio of two angles is a plain number: calc(1pi / 2pi) is 0.5.
  • Everything else (%, comparisons, boolean logic) reads the radians value and produces a plain number.

Unit mismatch checking rejects nonsense at compile time, reading the expression as written:

  • calc(0.25pi + 5) throws — adding an angle to a unitless number is ambiguous (% counts as unitless for this check).
  • calc(90deg * 45deg) throws — multiplying two angles has no meaning here.
  • Angle-ness propagates through a product: calc((90deg * 2) + 5) throws, because the product is still an angle.
  • The static check only sees literals: calc(x + 5) is never rejected, even when x holds an angle. At runtime the result is still an angle — the plain 5 is read as radians.

Angle Display

Interpolating an Angle into a template literal or log() shows it in its written unit — 90deg, 0.5pi, 1.5708rad — not the raw radians number. Use .rad or .deg when you need a bare number in text.

Degree-based color methods: hueShift, analogous, and splitComplementary read a bare number argument in degrees. An Angle value — however it arrives: literal, variable, array element, or the return value of a function you defined — is converted exactly, so hueShift(90deg), hueShift(90), and let turn = 0.5pi; hueShift(turn) all shift by 90°. Everywhere else in the language, a bare number is radians.

Color(L, C, H) follows the same rule: a bare H is degrees, and an Angle H auto-converts — Color(0.6, 0.15, 90deg) stores a hue of 90. The .hue property returns a plain number in degrees. See Color § Hue.

Note: The pi suffix only works on numeric literals. To scale π by a variable, multiply inside calc()calc(x * 1pi) — which keeps the result an Angle. mpi(x) also multiplies by π but returns a plain number, so it reads as radians in numeric slots and as degrees in the color methods above.

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). Both bounds are ordinary expressions — variables, member accesses, indexes, and function calls all work without a calc() wrapper: for (i in 1..points.length), for (i in first[0]..limits.max). (Remember ranges are inclusive: to visit array indexes, prefer for (p in points) or for ([p, i] in points) over computing bounds.)

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).

Loop Control: continue and break

continue; skips the rest of the current iteration and moves to the next one; break; exits the loop entirely:

// Draw circles at 0, 20, 40, 80, 100 — skipping 60
for (i in 0..5) {
  if (i == 3) {
    continue;
  }
  circle(calc(i * 20), 50, 8);
}

// Stop the first time a threshold is crossed
for (i in 0..100) {
  if (calc(i * i) > 500) {
    break;
  }
  M calc(i * 10) calc(i * i)
}

Both work in range loops and for-each loops (over arrays and objects), and inside loops in text blocks. A typical use with glyph layout — skip whitespace without drawing:

for (g in glyphs) {
  if (g.isWhitespace) {
    x = calc(x + g.advanceWidth);
    continue;
  }
  M x y
  g.draw()
  x = calc(x + g.advanceWidth);
}

Placement rules:

  • In nested loops, continue/break control the innermost enclosing loop.
  • They may appear directly in a loop body or inside if/else branches nested in it.
  • Everything else is a boundary: fn bodies, lambdas, callback blocks (Grid.fill, .map, …), apply { } blocks, path blocks (@{ }), and text blocks (outside their own loops). Using continue or break there — or outside any loop — is a compile error: 'continue' is only valid inside a for loop.
  • break and continue are reserved words and cannot be used as variable names.

Note: the loop-size safety limit (32,000 iterations) is checked against the range before the loop runs, so a break cannot make an over-limit range acceptable.

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.

Functions are values: pass one to another function by name and call it through the parameter.

fn double(x) { return calc(x * 2); }
fn callWith(f, v) { return f(v); }

let n = callWith(double, 5);   // 10

(Note: apply is reserved for layer blocks and can't be used as a function name.)

A function body may either return a value, or fall through after emitting path commands — in that case the call produces a path segment that can be used directly in path context:

fn square(x, y, size) {
  rect(x, y, size, size);
}
square(10, 10, 50);          // emits the rect's path commands

Scoping: functions vs lambdas

Named functions are dynamically scoped: a free name inside the body (one that isn't a parameter or a local let) resolves against the caller's scope at call time. Because top-level lets are visible from almost every call site, this usually behaves the way you'd expect — but a caller-local variable of the same name will take precedence:

fn f() { return amt; }
fn g() { let amt = 7; return f(); }
let amt = 1;
let a = f();   // 1  — resolves from the top level
let b = g();   // 7  — resolves from g's scope, the call site

If you need a function that remembers the variables where it was written, use a lambda.

Lambdas

A lambda is a function literal, written with the same block syntax used by array methods and other builders, and stored or passed like any other value:

let add = {|a, b| return a + b; };
let one = {|| return 1; };          // zero parameters

let three = add(1, 2);

Unlike named functions, lambdas are lexically scoped: the body captures the scope where the lambda was written (a closure), not the caller's scope.

let scale = 3;
let times = {|x| return calc(x * scale); };

fn caller() {
  let scale = 100;      // does NOT affect the lambda
  return times(2);
}
let six = caller();     // 6

Capture is by reference — if a captured variable is reassigned later, the lambda sees the new value. Loops create a fresh scope per iteration, so lambdas created inside a loop each capture that iteration's values:

let fns = [];
for (i in 1..3) {
  fns.push({|| return i; });
}
let first = fns[0];
let last = fns[2];
// first() is 1, last() is 3  (bind to a name before calling — see limitations)

Lambda bodies have the same dual mode as named functions: return a value, or fall through after path commands to produce a path segment.

Applying workers with <<. Anywhere a callback builtin accepts a trailing block, a lambda (or a named function) can do the same job — applied with the << operator, never passed inside the parentheses. (Constructor binding blocks are the exception — see the limitations below.) A worker earns its keep when it's reused:

let ease = {|t| return calc(t * t * (3 - 2 * t)); };
let rows = [0.2, 0.5, 0.9].map() << ease;
let cols = [0.1, 0.6].map() << ease;
let doubler = {|v| return calc(v * 2); };

let a = [1, 2, 3].map() << doubler;      // same as .map {|v| ...}
let b = [1, 2, 3].map() << {|v| return calc(v * 2); };   // inline literal

let grid = Grid(4, 4, { xDim: 25, yDim: 25 });
grid.fill() << {|row, col| return calc(row * 4 + col); };

This works for array .map/.filter/.reduce/.sort, Grid.fill/.forEach/.map, and variableOffset/compoundVariableOffset. The parentheses keep their ordinary parameters — reduce(init) << f — and the worker stays outside them. The full rules (evaluation order, chaining, interaction with merge) live in Applying workers under the << operator. The old argument form (map(f)) was removed before any production use; it now errors with a pointer to <<.

Current limitations:

  • A lambda must be called through a plain name (f(1, 2)). Calling the result of an expression — fns[0](5), obj.f(1), or an immediately-invoked literal — is not yet supported: assign to a let first.
  • A lambda literal cannot appear inside a call in path-argument position (M use({|x| ...}) 0) — path arguments stop at |. Bind the lambda to a variable and pass the name instead. (Worker application is unaffected: << {|x| ... } sits in ordinary expression position.)
  • Constructor binding blocks (LinearGradient(...) {|g| ...}, Marker, Pattern, filters, Grid(...) {|g| ...}) still require a literal trailing block — << worker application is a possible future extension there.

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 is an ambient global — so is viewbox, which exposes the dimensions set by define ViewBox(…); see Reading the viewbox.

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 a plain number of radians
deg(radians) Convert radians to a plain number of degrees
normalizeAngle(angle) Wrap an angle into the [0, 2pi) range — angle-preserving
// Use degrees instead of radians
let angle = rad(45);
M calc(cos(angle) * 50) calc(sin(angle) * 50)

These return plain numbers, not Angle values — handy when a bare number is what you want (e.g. in a template literal). For carrying an angle through your program, an angle-suffixed literal (45deg) does the same job and keeps its unit; its .deg/.rad members cover most conversion needs.

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
smoothstep(edge0, edge1, x) Hermite ease from 0 to 1 as x crosses from edge0 to edge1
bump(t, center, spread) Raised-cosine kernel: 1 at center, easing to 0 at center ± spread
// 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

// Ease a width profile in over the first quarter of a stroke
let w = smoothstep(0, 0.25, t);  // t = 0.5 → 1 (fully eased in)

smoothstep. smoothstep uses the GLSL argument order and formula: x is clamped into the [edge0, edge1] range as t = clamp((x - edge0) / (edge1 - edge0), 0, 1), then eased as t * t * (3 - 2 * t). The result rises smoothly from 0 to 1 with zero slope at both edges. Values of x outside the range saturate at 0 or 1. Swapping the edges reverses the ramp — GLSL leaves that case undefined; Pathogen defines and tests it. When edge0 === edge1 the ramp collapses to a hard step: 0 for x below the edge, 1 above it, and NaN exactly at x === edge0.

bump. bump(t, center, spread) is the raised-cosine kernel for building width envelopes: it peaks at exactly 1 when t === center, eases smoothly down to 0 as |t − center| approaches spread, and is exactly 0 (with zero slope at the boundary) everywhere outside that window — a self-contained hill you can sum with other bumps. The formula is 0.5 * (1 + cos(PI() * clamp(abs(t - center) / spread, 0, 1))). spread must be positive: a spread of 0 returns 0 everywhere (and NaN exactly at t === center), and a negative spread clamps the ratio to 0 and returns 1 everywhere. Because it uses cosine, bump is deterministic on any one engine but not bit-pinned across engines the way the hash family is.

// Two bumps summed into one asymmetric envelope (a width profile)
M 20 100
for (i in 1..48) {
  let t = i / 48;
  L calc(20 + t * 160) calc(100 - 10 * bump(t, 0.35, 0.3) - 6 * bump(t, 0.78, 0.18))
}

Angle-Preserving Functions

These functions return a value in the same space as their inputs, so an Angle in means an Angle out — the result keeps its unit for display and for the degree-based color methods. Bare-number inputs produce bare numbers.

Function Arguments that set the result's unit
abs(x) x
min(a, b, ...), max(a, b, ...) all
lerp(a, b, t) a, b
clamp(value, min, max) all
map(value, inMin, inMax, outMin, outMax) outMin, outMax
normalizeAngle(angle) angle
randomRange(min, max) min, max
hashRange(n, min, max, seed?) min, max

Arguments outside those slots are plain numbers by contract — lerp's t is a ratio, and map's result lives in its output range, which is why only outMin/outMax decide the unit. The display unit comes from the first Angle among the deciding arguments — lerp(0deg, 0.5pi, t) prints in degrees. (randomRange and hashRange are defined under Random and Hash & Noise.)

Don't mix bare numbers and Angles in the deciding slots. A bare number there is read as radians, and the result still takes the Angle's unit — min(90deg, 1) is 57.2957795131deg, not 1deg. Give every deciding argument a unit, or give none of them one.

// Random hue jitter, reproducible on every recompile — the pi-suffixed
// range keeps each hashRange result an Angle, so hueShift reads ±90°
let c = Color('#c00');
for (i in 1..9) {
  let swatch = PathLayer(`jitter-${i}`) ${
    stroke: none;
    fill: c.hueShift(hashRange(i, -0.5pi, 0.5pi));
  };
  swatch.apply { rect(0, calc(i * 24), 20, 20); }
}

Everything else is unchanged: functions that consume angles into a plain result (sin, cos, deg, polarX, the rounding family — round(90deg) is 2, because it rounds the radians) still return bare numbers, and functions that compute an angle (atan2, rad, mpi) still return plain radians — see Angle Units.

Behavior change: these functions used to flatten angles to bare radians — c.hueShift(randomRange(-0.5pi, 0.5pi)) shifted by at most ±1.57° because the color methods read bare numbers as degrees. The angle now survives the call, so that program shifts within ±90°. Interpolating such a result also prints with its unit: ${lerp(0deg, 90deg, 0.5)} is 45deg, no longer 0.7853981633974483.

Easing

The easing trio — easeIn(t), easeOut(t), easeInOut(t) — are the callable forms of the Easing enum members used for gradient easing. Same formulas, so the curve easeInOut(t) traces is the curve the gradient renderer applies for Easing.EaseInOut:

Enum member Callable form Formula
Easing.Linear — (identity) t
Easing.EaseIn easeIn(t)
Easing.EaseOut easeOut(t) 1 − (1−t)²
Easing.EaseInOut easeInOut(t) 2t² below ½, 1 − 2(1−t)² above
Easing.Smoothstep smoothstep(0, 1, t) t²(3 − 2t)

These are quadratic eases — CSS's ease-in family is cubic-bézier, close but not identical. Inputs outside [0, 1] clamp to the nearer end.

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, and recompiling the same program produces different output. For reproducible randomness use hash01(i) with a loop or element index — hashRange(i, min, max) is the deterministic randomRange — see Hash & Noise.

Hash & Noise

Deterministic randomness: the same inputs always produce the same output, so recompiles are repeatable and a "seed" is just another argument.

Function Description
hash01(n, seed?) Deterministic hash of integer n to [0, 1); seed defaults to 0
noise(x, seed?) 1D value noise: smooth deterministic wobble of continuous x, range [0, 1); seed defaults to 0
noise2(x, y, seed?) 2D value noise: the same wobble over an x/y field, range [0, 1); seed defaults to 0
hash11(n, seed?) hash01 remapped to [-1, 1) — signed jitter; seed defaults to 0
hashRange(n, min, max, seed?) hash01 scaled to [min, max) — the deterministic randomRange; seed defaults to 0
// Jittered tick marks — identical on every recompile
for (i in 0..18) {
  let x = i * 10 + hash01(i) * 4;
  M x 0
  L x calc(10 + hash01(i, 1) * 20)
}

Determinism. hash01 — and the noise functions built on it — use only integer bit-mixing and exactly-specified IEEE arithmetic (floor, no trigonometry), so they return identical values for identical arguments on every machine and JavaScript engine: CLI, playground, and VS Code preview agree, today and on every future recompile. The hash constants are a fixed contract; changing them would be a breaking change.

Seeds. The optional seed (default 0, so hash01(i) is hash01(i, 0)) selects an independent stream: hash01(i, 0) and hash01(i, 1) are two unrelated sequences over the same indices. Use it to give each layer or element family its own randomness — hash01(i, layerIndex) — instead of ad-hoc arithmetic like hash01(i * 7 + layerIndex * 1013).

Ranges. hash11(n, seed?) is hash01 remapped to [-1, 1) — the natural shape for signed jitter, e.g. 1 + hash11(i, layerIndex) * 0.2 for a ±20% wobble factor. hashRange(n, min, max, seed?) scales the same hash into [min, max): a drop-in deterministic replacement for randomRange(min, max) — just give it the index you want the value pinned to.

Integers only. Every hash function truncates n and seed to 32-bit integers before hashing: hash01(0.9) equals hash01(0), and non-finite inputs (NaN, Infinity) truncate to 0 rather than propagating. Smoothly varying a continuous input is what noise() is for.

Noise. noise(x, seed?) interpolates hash01 smoothly along the number line: at every integer k, noise(k) equals hash01(k) exactly, and between integers the two neighboring lattice values are blended with a smoothstep fade, so the result is continuous with zero slope at each lattice point. This is value noise (lattice values interpolated), not gradient/Perlin noise, so 2D fields show some axis-aligned structure. Unlike hash01, noise locates the lattice with floor — negative inputs interpolate as expected, and a non-finite x yields NaN rather than truncating to 0 (the seed still truncates like hash01's).

Frequency and seeds. Scale the input to set the wobble frequency — noise(t * 8) wobbles eight times as fast as noise(t) — and pass a seed for independent streams, exactly as with hash01.

2D noise. noise2(x, y, seed?) extends the same construction to two dimensions, bilinearly blending the four surrounding lattice corners with the fade on both axes. At integer corners it returns a hash of the coordinate pair (there is no exposed 2D hash).

// A smooth wandering line — noise wobbles, hash01 jitters
M 10 calc(100 - noise(0) * 40)
for (i in 1..60) {
  let t = i / 60;
  L calc(10 + t * 180) calc(100 - noise(t * 6) * 40)
}

vs. Cycler. Cycler (below) is the other deterministic tool: it assigns values round-robin by call order, while the hash family assigns them positionally by index.

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 as a plain number in radians (not an Angle value).

.distance

Returns the distance.

.turn(deltaAngle)

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

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

.scale(factor)

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

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

.mirror()

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

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

Path Functions

These functions generate complete path segments.

circle(cx, cy, r)

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

circle(100, 100, 50)

Output: A full circle using two arc commands.

rect(x, y, width, height)

Draws a rectangle.

rect(10, 10, 80, 60)

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

Draws a rectangle with rounded corners.

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

polygon(cx, cy, radius, sides)

Draws a regular polygon.

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

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

Draws a star shape.

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

line(x1, y1, x2, y2)

Draws a line segment.

line(0, 0, 100, 100)

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

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

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

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

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

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

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

Draws a cubic bezier curve.

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

polarCubicBezier(start, pv1, pv2, end)

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

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

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

PolarVector methods compose naturally for handle manipulation:

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

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

moveTo(x, y)

Returns a move command. Useful inside functions.

moveTo(50, 50)

lineTo(x, y)

Returns a line command.

lineTo(100, 100)

closePath()

Returns a close path command.

closePath()

cubicSpline(points)

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

Point schema:

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

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

quadSpline(start, points, end)

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

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

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

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

clippedQuadSpline(start, points, end)

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

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

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

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

Grid Functions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Usage with Layers and Transforms

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

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

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

A convenience wrapper for one-line grid drawing:

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

Context-Aware Functions

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

Polar Movement

polarPoint(angle, distance)

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

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

polarOffset(angle, distance)

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

polarMove(angle, distance)

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

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

polarLine(angle, distance)

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

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

Arc Functions

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

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

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

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

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

arcFromPolarOffset(angle, radius, angleOfArc)

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

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

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

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

Comparison with arcFromCenter:

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

Heading Control

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

heading(angle)

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

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

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

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

turn(delta)

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

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

After drawing commands:

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

ctx.heading

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

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

Tangent Functions

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

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

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

tangentLine(length)

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

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

After native SVG commands:

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

tangentArc(radius, sweepAngle)

Draws an arc continuing tangent to the previous command.

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

After native SVG commands:

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


Color

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

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

CSSVar

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

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

Using Functions Inside calc()

Math functions can be used inside calc():

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

Path functions are called at the statement level:

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

ViewBox

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

Syntax

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

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

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

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

Arguments

Arguments are expressions and may use variables, calc(), member accesses (sheet.w), array indexes (sizes[0]), function calls, 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

Reading the viewbox

The viewbox global (lowercase) exposes the values set by define ViewBox as a read-only struct. This removes the update-two-places pattern: a full-canvas background no longer repeats the canvas dimensions, so changing the viewBox is a one-line edit:

define ViewBox(0, 0, 880, 280);
define default PathLayer('base') ${ fill: #fff; };
let {width, height} = viewbox;
rect(0, 0, width, height);
Member Value
viewbox.originX First argument to define ViewBox
viewbox.originY Second argument
viewbox.width Third argument
viewbox.height Fourth argument

The struct's type is ViewBox — a typo like viewbox.w errors with Property 'w' does not exist on ViewBox.

All the usual access forms work — dot access, destructuring, and rest patterns:

define ViewBox(-100, -100, 200, 200);
let cx = calc(viewbox.originX + viewbox.width / 2);
let {originX, originY, ...size} = viewbox;

viewbox is available anywhere after the define ViewBox statement has executed — inside fn bodies, layer().apply { } blocks, and path blocks alike. The rule is execution order, not source order: a function declared above the define can still read viewbox when it is called after the define has run, because the lookup happens at call time.

Reading before defining is an error

Reading viewbox before define ViewBox(…) has executed — including in a program with no define ViewBox at all — is an error:

// Error: viewbox is not available until define ViewBox(...) has run
let {width} = viewbox;
define ViewBox(0, 0, 200, 200);

The implicit 0 0 200 200 default (Default ViewBox) applies to rendering only; the viewbox global never falls back to it. To read the viewbox, declare it.

Shadowing and read-only semantics

  • The struct is read-only: assigning to a member (viewbox.width = 5;) is a compile error — Cannot assign to property 'width'.
  • Each read returns a fresh copy, so let a = viewbox; gives you an independent snapshot.
  • A user variable named viewbox shadows the global — let viewbox = 5; is legal and existing programs keep their meaning. The shadow follows normal scope rules: a let viewbox inside a block shadows only within that block, and the global is visible again outside it. Within a scope that shadows it, there is no way to reach the ambient global.
  • Capitalized ViewBox remains a keyword and is only valid in define ViewBox(…).

Default ViewBox

If a program contains no define ViewBox statement, the rendered viewBox defaults to 0 0 200 200. This default applies to rendering only — the viewbox global does not inherit it and errors when read in a program without define ViewBox (see Reading the viewbox).

Placement

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

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

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

Errors

The compiler rejects:

  • Duplicate define ViewBox — only one viewBox per program.
  • Zero or negative width or height — these are invalid SVG dimensions.
  • default modifierdefine default ViewBox(…) is not allowed; only PathLayer and TextLayer accept default.
  • Non-numeric arguments — every argument must evaluate to a finite number.
  • Reading viewbox before define ViewBox has run — see Reading before defining is an error.
  • Assigning to a viewbox member — the struct is read-only; see Shadowing and read-only semantics.

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.

The viewbox global reads only from define ViewBox. When the viewBox comes from a CLI flag (or the implicit default), reading viewbox still errors — the flag affects rendering, not the program's variables. A program that reads viewbox must declare its viewBox in source.

Why source, not configuration

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

  • Layers — the rest of the define family (PathLayer, TextLayer, GroupLayer)
  • CLI — using --viewBox/--width/--height flags alongside source-defined viewBox
  • Path Context (ctx) — the other ambient global, tracking the pen position during evaluation

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

Every program has exactly one default layer — the layer that receives all bare path commands (commands outside any layer().apply block). You don't create it; it always exists. define default PathLayer('name') simply names and styles that one layer:

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

// These commands go to 'main' — the default layer
M 10 10
L 90 10
L 90 90
Z

If you never write define default PathLayer, the default layer is still there: bare commands flow into it and it appears in the output named 'default' (with no styles). There is no separate "global" layer alongside the default — bare commands, the pen position (ctx), and any top-level transform all belong to this single default layer, whether or not you have named it.

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

Querying Labeled Geometry

Path commands inside apply { } blocks can carry as segment(...) / as endpoint(...) labels. A labeled layer answers geometry queries by name from anywhere:

let pl = PathLayer('outline') ${ stroke: #333; fill: none; };
pl.apply {
  M 10 10
  h 60 as segment('top');
  v 40 as endpoint('corner');
}

let top = layer('outline').segment('top');   // ProjectedPath (absolute coords)
let c = layer('outline').point('corner');    // Point(70, 50)

segment('name') returns a ProjectedPath with the full sampling API (get, tangent, partition, boundingBox, ...); point('name') returns the labeled vertex as a Point. See Segment Labels & Corner Suffixes for the full query surface.

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 any expression — a variable, a template literal, an array element, a member access, or a function call — both where a layer is defined and where layer(...) routes to it:

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

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

Expression names make routing data-driven. Round-robin styling, for example, needs no if-chain — compute the name, or index into a list of layer values:

let shards = [shard0, shard1, shard2];   // layer values from PathLayer(...)
for ([piece, i] in pieces) {
  layer(`shard${i % 3}`).apply {          // by computed name…
    piece.draw();
  }
  // …or equivalently: layer(shards[calc(i % 3)]).apply { ... }
}

Dynamic Layer Creation

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

Constructor Expression

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

The style block is optional:

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

Style Mutation with <<

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

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

Explicit .styles Property

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

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

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

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

TextLayer Constructor

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

Accessing Layer Properties

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

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

Coexistence with define

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

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

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

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

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

Style Properties

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

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

Each property is a semicolon-terminated declaration:

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

Output Format

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

import { compile } from 'pathogen-lang';

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

  M 0 0 H 100 V 100 H 0 Z

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

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

Programs without any define statements produce a single implicit layer:

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

Full Example

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

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

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

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

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

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

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

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

TextLayer

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

Defining a TextLayer

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

text() — Two Forms

Inline form — simple text content:

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

Block form — mixed text runs and tspan children:

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

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

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

tspan() — Only Inside text() Blocks

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

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

Template Literals

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

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

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

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

TextLayer Output Format

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

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

Restrictions

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

Style Blocks

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

Style Block Literals

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

Merge Operator (<<)

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

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

Property Access

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

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

Expression Evaluation in Values

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

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

Variables and Interpolation in Values

This is the same expression evaluation from the previous section applied to variables: a bare identifier is just an expression that resolves to its value, and a backtick template is an expression that interpolates with ${...}:

let family = "Noto Sans";
let size = 16;

let textStyles = ${
  font-family: family;              // resolves to "Noto Sans"
  font-size: `${size * 2}`;         // interpolates to "32"
};

Double-quoted strings are always literal — they never interpolate. font-family: "family"; is the literal family name family, not the variable; use a bare identifier or backticks for dynamic values. (Inside a ${...} interpolation, at most one nested level of { } braces is supported.)

Dynamic Function Arguments

A template doesn't have to span the whole value — a backtick fragment can sit anywhere inside it, including function arguments. Each fragment evaluates and splices into the surrounding text before the value is checked:

let amount = randomRange(1.1, 2.2);

define PathLayer('soft') ${
  fill: hotpink;
  filter: blur(`${amount}`px);   // splices to e.g. "blur(1.63px)"
};
layer('soft').apply { circle(100, 100, 60); }

These four forms are all available, and all produce blur(1.5px) brightness(1.4) for softness = 1.5, level = 1.4:

Form Example Use when
Fragment, unit outside blur(`${softness}`px) Most cases — the unit stays visible as CSS
Fragment, unit inside blur(`${softness}px`) The unit itself is computed
Whole-value template `blur(${softness}px)` The whole value is one interpolated string
Bare identifier brightness(level) The argument is a unitless number — a length or angle here is a compile error

The quoting rule above still applies to fragments: a backtick inside a double- or single-quoted string is literal text, not a splice point.

A bare identifier works as a function argument when the variable holds a number — the compiler substitutes the value, as it already does for Color and CSSVar() references:

let level = 1.4;
define PathLayer('bright') ${ filter: brightness(level); };

Substitution is not unit-aware, and the compiler checks the result. A numeric variable substitutes as a bare number, so filter: blur(amount); would emit blur(4) — a unitless length, which is invalid CSS. Rather than emit a declaration the browser silently drops, Pathogen rejects it:

blur() takes a length — "4" needs a unit (try 4px). Lengths without a valid
unit are invalid CSS and the browser drops the whole declaration.

The check runs on the final value, so it catches the mistake whether the number was typed literally, substituted from a variable, or interpolated. Use a bare identifier for the unitless filter functions (brightness, contrast, grayscale, invert, opacity, saturate, sepia); whenever the argument is a length or an angle, attach the unit with a template fragment. Literal arguments written with units (blur(2px), hue-rotate(-90deg)) are always left exactly as typed. See CSS Function Values for the full per-function unit rules.

Interpolation is a convenience, not an escape hatch: every interpolated result — whole-value or fragment — is validated against the same style-value allow-list as hand-written values (see the security model). Note the two calc()s are different things: an unquoted calc(12 + 15) is Pathogen arithmetic resolved before emission, while a calc() that survives into the emitted CSS string — including via interpolation — is rejected.

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 — Angle values work here whether written inline or carried in a variable:

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 Layer Context

Outside any layer().apply block, ctx refers to the default layer's context — including its transform. This is true whether or not you have named the default layer with define default PathLayer:

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

Inside Apply Blocks

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

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

Combined Transforms

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

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

Transform Convenience Properties

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

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

Shorthands for translate and scale accept comma-separated values:

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

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

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

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

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

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

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

Per-Layer Isolation

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

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

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

GroupLayer

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

Definition

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

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

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

Adding Children with .append()

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

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

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

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

Output SVG:

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

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

Nesting Groups

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

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

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

Transforms

GroupLayers support both style block transforms and imperative transforms:

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

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

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

Moving Layers Between Groups

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

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

No Apply Blocks

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

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

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

Limitations

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

Path Blocks

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

Syntax

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

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

Drawing a Path Block

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

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

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

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

Assigning the draw result

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

Drawing at a specific position

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

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

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

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

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

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

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

Drawing a ProjectedPath in place

A ProjectedPath already knows where it lives, so .draw() on one draws it exactly there — no anchor arguments, no cursor dependence:

let pieces = plate.cut(knife);
let placed = pieces[0].project(40, 60);
placed.draw()                    // the piece, exactly where placed says it is

for (seam in placed.segmentAll('cut')) {
  seam.draw()                    // each healed seam, stroked on itself
}

This is the idiom for decorating query results — seams, labeled runs, offsets — where the value's own coordinates are the target. The cursor advances to the path's endpoint, and the same ProjectedPath comes back for chaining.

The drawTo anchor contract. drawTo(x, y) places the value's startPoint — the first inked point — at (x, y). There used to be a trap here: a cut piece's startPoint reported the frame origin rather than where the piece's ink actually starts, so drawTo(p.startPoint.x, p.startPoint.y) silently shifted whole pieces. startPoint is now truthful for every value (get(0) always agrees with it), so drawTo anchors the ink at the target for pieces and seams alike. .draw() remains the one-word spelling for drawing a projected value in place.

One distinction worth knowing: proj.drawTo(x, y) puts the ink at (x, y); M x y followed by block.draw() seats the pen there and lets a leading m in the block offset from it. For blocks with no leading move the two agree exactly.

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 The first inked point — where drawing begins. Point(0, 0) for blocks that start drawing immediately; a block that opens with m moves reports where the ink actually lands, so get(0) always agrees with startPoint
endPoint Point Final cursor position (relative to origin)
isEmpty boolean true when the block contains no path commands — e.g. a space glyph from fromGlyph, or subPath(t, t)

ProjectedPath

Same properties as PathBlock but with absolute coordinates.

subPathCommands entries

Each entry in subPathCommands is an object with:

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

Control Flow Inside Path Blocks

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

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

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

Accessing Outer Variables

Path blocks can read variables from enclosing scope:

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

First-Class Values

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

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

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

Using Path Metadata

Access path properties for layout calculations:

let segment = @{ v 20 h 30 };

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

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

Restrictions

Path blocks enforce these rules at runtime:

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

Parametric Sampling

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

These methods work on both PathBlock values and ProjectedPath values.

get(t) → Point

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

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

tangent(t){ point, angle }

Returns the point and tangent angle at fraction t. The angle is the direction of travel, as a plain number in radians.

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)

On cut and boolean results, the normal points away from the piece's material — guaranteed. cut() and the boolean operations each canonicalize their result's winding so material always lies on the same side of the direction of travel, and the normal is a fixed rotation of that travel — so on any seam or boundary edge of a piece, normal(t) faces out of the piece: glue tabs, ticks, and offsets aimed along it point away from the material with no direction test needed. Two footnotes: on a hole's boundary, "away from the material" points into the hole (that is the outside of the material there); and on hand-authored paths there is no such guarantee — the normal is simply left-of-travel, and which side is "outside" depends on how you wound the 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, options?) → PathBlock / ProjectedPath

Creates a parallel path offset by distance units. Positive values offset to the left of the travel direction, negative to the right. On pieces produced by cut() and results of boolean operations, winding is canonicalized with material on the left, so a positive distance always grows the piece outward — including through hole boundaries.

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

Corners. Where two segments meet, the join is chosen by the corner and the join option:

  • Gentle corners between two straight segments keep a sharp miter (the true corner point), up to a miter length of 2× the offset distance — so rectangular offsets stay rectangles.
  • Sharper corners, and every corner involving a curve, get a bevel: each segment is offset with its own normals and a short connecting line bridges the gap. Join geometry is never folded into a curve's own shape — a sharp corner cannot distort the curve next to it.
  • offset(d, { join: 'round' }) replaces the bevels with circular arcs of radius |distance| centered on the original corner — the offset a rolling pen would draw. { join: 'bevel' } forces bevels even at gentle straight corners; the default is 'miter' (miter where safe, bevel beyond the limit).
  • Join options apply only to convex corners — the side the offset opens a gap on. Concave corners are always trimmed back to where the two offset sides cross (exactly for straight segments, by curve subdivision for curves), never given an external connector. One current limitation: a concave corner where an arc segment meets the join falls back to a connector; and an offset distance large enough to swallow a feature entirely (an inward offset wider than half the shape) can still self-intersect rather than collapsing.

A connector between two segments that carry the same segment label inherits that label, so a labeled edge that turns a corner still answers as one run.

Curves. Cubic and quadratic segments are offset as true parallel curves: the curve is subdivided where it bends strongly and each piece is offset via its control polygon, so a deep scoop's offset stays distance away along its whole length — at the cost of the output containing more curve segments than the input. Arcs offset by radius adjustment, quadratics are emitted as cubics.

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()
}

rotate(angle, origin?) → PathBlock / ProjectedPath

Rotates the path by angle around origin — a Point, defaulting to the block origin (0, 0) when omitted. The angle accepts plain radians or Angle values (0.5pi, 45deg).

let arm = @{
  h 50
  v 10
};
let quarter = arm.rotate(0.5pi);          // about the block origin
let spun = arm.rotate(45deg, Point(25, 5));  // about the arm's center

Unlike rotateAtVertexIndex, the result is not re-based: the geometry rotates about the pivot inside the block's own coordinate frame and stays where it is. A piece that carries placement — a cut() shard, for example — keeps that placement, so rotating it in place needs no compensation:

let plate = @{
  h 60
  v 40
  h -60
  z
};
let knife = @{
  m 30 -10
  l 0 60
};
let pieces = plate.cut(knife);
for ([p, i] in pieces) {
  let pb = p.boundingBox();
  let c = Point(calc(pb.x + pb.width / 2), calc(pb.y + pb.height / 2));
  let spunPiece = p.rotate(0.1, c);
  M 20 20
  spunPiece.draw();
}

rotate(a) with no origin is equivalent to rotateAtVertexIndex(0, a) when the path starts at the origin. Rotation preserves path length and curve types; arc commands have their rotation parameter adjusted. Segment and endpoint labels survive (see Labels Survive Derived Paths).

scale(sx, sy) → PathBlock / ProjectedPath

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

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

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

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

subPath(startT, endT) → PathBlock

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

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

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

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

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

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

Edge cases:

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

Transforms on ProjectedPath

Projected paths return results in absolute coordinates:

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

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

For mirror() on a ProjectedPath, the mirror line passes through the projection's start point. For rotateAtVertexIndex(), the rotation center is the absolute vertex position. For rotate(), the origin is an absolute point; when omitted, the pivot defaults to the projection's start point.

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. << has one more job on PathBlocks: applying a worker function to a variableOffset()/compoundVariableOffset() call written without a trailing block.

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.

Name-based alternative: instead of a numeric vertex index, you can label a vertex with as endpoint('name') and round it with pb.vertex('name').fillet(radius) — or attach the fillet where you draw the corner with with fillet(radius). Labels don't break when commands are added earlier in the path. See Segment Labels & Corner Suffixes.

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

The four set operations — union, difference, intersection, and xor — combine two closed paths. Both operands must be closed (end with z or have coincident start and end points). The result preserves original curve types — no linearization. To slice a path along open cut lines instead, see Cutting Paths.

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(0, 0, 30); };
let b = @{ circle(0, 0, 30); };
let combined = a.project(50, 50).union(b.project(70, 50));

difference(other) → PathBlock

Subtracts other from the path:

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

intersection(other) → PathBlock

Returns only the overlapping region:

let a = @{ circle(0, 0, 30); };
let b = @{ circle(0, 0, 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(0, 0, 30); };
let b = @{ circle(0, 0, 30); };
let exclusive = a.project(50, 50).xor(b.project(70, 50));

Requirements and behavior

  • Both paths must be closed for union, difference, intersection, and xor. Open paths throw an error. (cut() is the exception — its cutter is usually open, and its subject may be too.)
  • The other argument can be a PathBlock or ProjectedPath.
  • Multi-component results produce multiple subpaths (M...z M...z).
  • All curve types (lines, cubics, quadratics, arcs) are preserved through the operation.
  • Results are always returned as PathBlock values (normalized to (0, 0) origin).
  • Segment and endpoint labels from both operands survive into the result (see Labels Survive Derived Paths).

Cutting Paths

Where the set operations combine two closed shapes, cut() slices one shape along cut lines — like drawing a knife across it — and hands back the resulting pieces. Each piece is a complete PathBlock, healed shut along the lines that cut it (a cut open path is the exception: its pieces stay open fragments).

Debug mode: cut() is not yet supported in the CLI's --annotated debug mode. It works normally everywhere else — CLI compilation, the playground, and the VS Code preview.

cut(cutter) → array of PathBlock

Cuts the path along every stroke of cutter and returns the pieces. The cutter may be a single PathBlock or ProjectedPath, or an array of them — every knife in the array cuts, exactly as if their strokes lived in one block. Arrays make cutters compositional: build knives in a loop and hand the whole set to one cut() call.

One conversion caveat: each array element is its own block, so its m/l deltas resolve relative to that block's own origin — not to where the previous knife in the array left off. If you're splitting an existing multi-stroke knife block into an array, recompute each knife's starting move relative to the origin rather than copying a chained delta verbatim; a copied delta compiles fine and cuts in the wrong place.

let disc = @{
  circle(0, 0, 90);
};
let knives = [];
for (k in 0..7) {
  let spokeAngle = calc(k * PI() / 4);
  knives.push(@{
    m calc(36 * cos(spokeAngle)) calc(36 * sin(spokeAngle))
    l calc(76 * cos(spokeAngle)) calc(76 * sin(spokeAngle))
  });
}
knives.push(@{
  circle(0, 0, 36);    // the ring the spokes anchor to
});
let panes = disc.cut(knives);    // eight ring panes + the medallion
let box = @{
  h 60
  v 40
  h -60
  z
};
let knife = @{
  m 30 -10
  l 0 60
};
let pieces = box.cut(knife);
log(pieces.length);    // 2

Each subpath of the cutter is one knife stroke, and strokes may be lines or curves. An open stroke slices the shape wherever it crosses. A closed loop acts as a cookie cutter, stamping out the region inside it — and the loop doesn't have to be authored with z: separate strokes whose endpoints meet are recognized as a loop geometrically:

let box = @{
  h 60
  v 40
  h -60
  z
};
let stamp = @{ circle(0, 0, 10); };
let pieces = box.cut(stamp.project(30, 20));
log(pieces.length);    // 2 — the stamped-out disk, and the box now carrying a hole

Alignment works exactly like the set operations: both blocks are overlaid in block-local coordinates, and you position the cutter with project():

let plate = @{ circle(0, 0, 40); };
let knife = @{
  l 100 20
};
let pieces = plate.project(50, 50).cut(knife.project(0, 40));
log(pieces.length);    // 2

Pieces keep their original placement inside the subject, so drawing them all at the same position reassembles the shape — and offsetting each one produces an exploded view:

let box = @{
  h 60
  v 40
  h -60
  z
};
let knife = @{
  m 30 -10
  l 0 60
};
let pieces = box.cut(knife);

for ([piece, i] in pieces) {
  M calc(20 + i * 10) 20
  piece.draw()
}

Cutting works on multi-contour subjects — a glyph, a donut, a shape with holes. Here the knife crosses both contours of an 'O' (from PathBlock.fromGlyph), so each piece's boundary follows the outer edge, the cut line, and the inner edge — two C-shapes:

@font "Inter";
let styles = ${ font-family: Inter; font-size: 96; };
let glyphs = PathBlock.fromGlyph("O", styles);
let knife = @{ m -10 -40 l 90 8 };
let pieces = glyphs[0].cut(knife);
log(pieces.length);    // 2 — two C-shaped pieces

A hole the cut misses isn't lost — it rides along as an extra subpath inside whichever piece contains it (inspect it with contours).

Open subjects can be cut too. Cutting an open path severs it at each crossing and returns the open fragments — no healing, since there is no interior to close:

let wave = @{
  q 20 -20 40 0
  q 20 20 40 0
};
let knife = @{
  m 30 -30
  l 0 60
};
let parts = wave.cut(knife);    // 2 open fragments

Because every piece is a full PathBlock, styling them individually is just iteration — cut once, then route pieces to differently-styled layers:

let warm = PathLayer(`warm`) ${ fill: #e0b17c; stroke: #40311f; stroke-width: 1; };
let cool = PathLayer(`cool`) ${ fill: #7c9ce0; stroke: #1f2540; stroke-width: 1; };

let disc = @{ circle(0, 0, 40); };
let knives = @{
  m -20 -50
  l 0 100
  m 20 -100
  l 0 100
  m 20 -100
  l 0 100
};
let slices = disc.cut(knives);    // 4 slices

for ([piece, i] in slices) {
  if (calc(i % 2) == 0) {
    warm.apply {
      M calc(50 + i * 6) 50
      piece.draw()
    }
  } else {
    cool.apply {
      M calc(50 + i * 6) 50
      piece.draw()
    }
  }
}

seams() → array of PathBlock

Every interior seam exists twice in the pieces — once in each adjacent piece — so decorating seams per piece draws each one twice (visible with dashed strokes, whose opposite-direction passes fill each other's gaps). seams(), called on the array cut() returns, answers with each physical seam exactly once:

let pieces = card.cut(creases);
for (seam in pieces.seams()) {
  foldLayer.apply {
    seam.project(20, 30).draw();    // each fold drawn once, by contract
  }
}

The returned seams are PathBlocks that keep subject-local placement, exactly like the pieces themselves — draw them at the same origin you draw the pieces (M x y + seam.draw(), or seam.project(x, y).draw()) and they land on the cuts. Each seam's direction of travel is unspecified (it comes from whichever piece was encountered first). A cookie cutter's ring is one closed seam (it closes with z, so dashes wrap); a knife split by a crossing knife counts as one seam per crossing-bounded fragment.

Details worth knowing:

  • Seam normals face outward. On any piece's seams and boundary edges, normal(t) points away from the piece's material — guaranteed by the same winding canonicalization that powers the cut (see the normal(t) docs for the hole footnote).
  • Chained cuts compose. Cut a piece again and its inherited seams stay identified: subPieces.seams() returns the new cut's seams plus each surviving fragment of the older seams, every physical stretch exactly once.
  • Severing an open path creates no healed boundary, so seams() on an open subject's fragments is empty.
  • seams() is answered from identity the cut records into the pieces — call it on arrays whose elements came from cut(). Hand-built pieces don't carry it, and transformed copies may change how sides pair (after offset() the two sides of a seam are genuinely different curves, so both return; corner-shaping ops may drop the identity entirely). When in doubt, query seams() on the cut result itself and transform the seams.

Cutting behavior

Arguments and results

  • The cutter argument can be a PathBlock or ProjectedPath; so can the receiver. Pieces always come back as PathBlock values, even from a ProjectedPath receiver.
  • Pieces keep their original placement inside the subject (like the set operations, results are normalized to a (0, 0) origin). Drawing every piece at one position reassembles the shape.
  • Piece order is deterministic but unspecified — style pieces by iterating, not by assuming which index is which.
  • Labels survive: pieces keep the subject's as segment(...) / as endpoint(...) names on their surviving boundary fragments, and every healed seam edge carries the automatic segment label cut (query the seams with segmentAll('cut')). See Labels Survive Derived Paths.
  • Name your knives. A cutter edge labeled as segment('valley') stamps the seams it heals with the sub-label cut.valley. segmentAll('cut') still returns every seam — sub-labeled or not, merged into runs exactly as before — while segmentAll('cut.valley') answers just that knife's seams. Unlabeled cutter edges, cookie boundaries, and bridging segments stay plain cut (a bridge that seals a gap inside one named knife's seam inherits that knife's name, so the run stays contiguous). Because cut() takes an array of cutters, each knife can carry its own name: mountain and valley folds, cut in one call, dashed differently. Query pseudo-selectors compose with the namespace — segment('cut:first'), segmentAll('cut.valley:atomic'). The cutter's endpoint labels do not propagate — seam identity is a per-edge affair.

Tolerances and fidelity

  • A cutter endpoint that lands on the subject's boundary — or close to it — snaps onto the boundary and completes the cut there (a T-junction). "Close" means about half a unit for typical viewBox-scale drawings, growing with the drawing's size (max(0.5, bounding-box diagonal × 0.001)) — so on very small coordinate systems the snap is proportionally generous. The same snapping applies when a stroke passes through a subject vertex.
  • All curve types are preserved through the cut; the healed edges follow the cutter's own geometry.

Strokes that don't cut

  • A stroke that ends deep inside the shape without reaching the far boundary does not cut — that stroke is ignored and the region stays whole. Cutting never invents geometry beyond the tolerance snap.
  • A stroke that only grazes the boundary tangentially, or runs collinear along an edge, also leaves the shape whole.
  • Portions of the cutter outside the shape (or inside a hole) are ignored.
  • A cutter that never touches the shape — or has no drawable strokes at all — returns a single-element array containing the original. Cutting an empty PathBlock returns an empty array.

Compound cases

  • Strokes crossing each other inside the shape subdivide it together: an X of two strokes produces four pieces from one region.
  • A subject mixing closed and open subpaths returns both kinds of pieces: healed closed pieces for the closed contours, open fragments for the open ones.
  • In rare degenerate cases — a fragment that can't be traced cleanly, or a sliver thinner than the geometric tolerance — cut() drops the fragment and emits a [warn] entry in the log output rather than failing.

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";

The source can also be a variable, as long as it is a top-level let bound to a plain string literal:

let family = "Inter";

@font family;
@font family 700;

Syntax:

@font "family-or-path" [weight];
@font <variable> [weight];
Part Required Description
Source Yes Font family name (e.g., "Inter"), file path (e.g., "./fonts/Custom.ttf"), or a top-level let variable bound to a string literal
Weight No Numeric weight 100–900 (default: 400)

Because fonts are loaded by the host environment before the program runs, a variable source must be resolvable statically: a top-level let whose value is a plain string literal. Referencing anything else fails with:

@font directive references 'x', which is not a top-level string variable.
Declare it at the top level: let x = "Family Name";

Font loading by environment:

  • CLI: Loads from local file paths (relative to source file), or resolves family names against font files on disk. Named lookup searches, in order: any directories in the PATHOGEN_FONT_DIRS environment variable (colon-separated), a fonts/ directory found by walking up from the source file (so a project-local font mirror works from anywhere in the repo), and the system font directories (/Library/Fonts, /System/Library/Fonts, ~/Library/Fonts on macOS; equivalent paths on Linux/Windows). Files are matched by the Google Fonts naming convention — @font "Playfair Display" 700; finds PlayfairDisplay-Bold.ttf — so a family name that works in the playground also compiles in the CLI when the font file is mirrored locally.
  • Playground: Fetches from Google Fonts CDN automatically. Google serves large fonts split into per-script subsets (a Korean family like "Nanum Gothic" or "Moirai One" ships as ~100 small slices); the playground loads the Latin subset up front and fetches additional slices automatically when the text you render needs them — no extra directives required

The directive is declarative metadata — the host environment loads fonts before compilation begins. If a font cannot be found, the CLI logs a warning and compilation continues; in the playground it is a compile error (what counts as "cannot be found" is explained below).

Curated families vs. any Google Font (playground)

The playground's font picker — opened by clicking the font-family value in the inspector — lists about 100 popular families: the curated list. @font is not limited to it. It accepts any family published on Google Fonts, so a display face that never appears in the picker still works — browse the catalog and paste the family name:

@font "Gravitas One";

The difference between the two tiers is what the playground knows about a family: it has the curated families' weight lists; for any other family it must ask Google.

In the curated list Not in the curated list
Loading Loads silently Loads, plus a dismissible warning: "Gravitas One" is not in the curated font list; loaded directly from Google Fonts.
Unavailable weight Snapped to the nearest known weight before any request — @font "Baumans" 900; loads its only weight, 400 The requested weight is tried first; if Google rejects it, the playground retries without a weight and takes Google's default: Gravitas One does not provide weight 700 on Google Fonts; using its default weight 400
Family can't be served Compile error reporting the CDN's reason Compile error. The name may be wrong or the network request may have failed — the browser cannot read Google's error details across origins. Check the spelling at Google Fonts

Warnings appear in the workspace's dismissible warning banner and never stop compilation. Weight substitution applies to both @font weights and font-weight in a style block. Other font-loading failures are also compile errors: a CSS generic family (@font "sans-serif"; — generics can't be fetched from Google Fonts, even though the picker lists them), an unresolvable variable (shown above), or a malformed directive. See Error cases for fromGlyph-specific errors.

A variable source pairs naturally with style blocks, letting a single declaration drive both the font load and the styles that use it:

let family = "Inter";

@font family;

let styles = ${
  font-family: `${family}`;
  font-size: 48;
};

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()
  x = calc(x + g.advanceWidth);
}

Note the plain assignment x = calc(...) — a let inside the loop body would declare a fresh per-iteration variable that shadows the outer x, so the cursor would never advance.

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

Glyph provenance and character classes

Each glyph PathBlock records where it came from and whether it drew anything:

Property Type Description
char string The source character this glyph was generated from (1 character)
codePoint number The Unicode code point of char (e.g. 32 for a space, 12288 for the ideographic space U+3000)
isWhitespace boolean true when the source character is whitespace (space, tab, newline, …)
isSpace boolean true for every whitespace character that is not a tab or a line break — regular space, no-break space, ideographic space U+3000, en/em/thin spaces, and the zero-width no-break space U+FEFF
isTab boolean true for the tab character U+0009
isNewline boolean true for line-break characters — \n, \r, vertical tab, form feed, and the Unicode line/paragraph separators U+2028/U+2029
isMark boolean true for combining marks — accents and vowel signs that overlay the previous glyph (see below)
isEmpty boolean true when the glyph produced no outline commands (spaces, and other outline-less characters)

char, codePoint, and the is* classifications exist only on glyphs produced by fromGlyph — reading them on any other PathBlock is an error. isEmpty works on every PathBlock (and ProjectedPath).

Use isNewline to honor hard line breaks during layout:

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

let marginX = 10;
let x = marginX;
let y = 60;
let lineHeight = 56;
for (g in glyphs) {
  if (g.isNewline) {
    y = calc(y + lineHeight);
    x = marginX;
    continue;
  }
  if (!g.isWhitespace) {
    M x y
    g.draw()
  }
  x = calc(x + g.advanceWidth);
}

isSpace, isTab, and isNewline partition isWhitespace exactly: every whitespace character is exactly one of the three, and a non-whitespace character is none of them. That is what makes the loop above safe — a whitespace glyph that is not a newline can only be a space or a tab, and both just advance the cursor. Two caveats worth knowing:

  • Tabs are not tab stops. A tab has no glyph in most fonts, so its advanceWidth is the font's placeholder-box width — commonly about half an em — not a jump to the next tab column. If tab stops matter, branch on isTab and compute the next stop yourself (or expand tabs to spaces before calling fromGlyph).
  • Windows line endings. \r\n is two characters, both isNewline; break-once code should treat a \r followed by \n as one break or normalize the input string first.

isWhitespace and isEmpty are not the same test, in either direction: isWhitespace classifies the source character, while isEmpty reports whether the outline is blank. An unmapped control character can be empty without being whitespace — and whitespace can be non-empty: a tab or newline has no glyph in most fonts, so it renders the font's placeholder box (some fonts' placeholder has a visible outline, some don't). That is why the layout example above skips on isWhitespace, not isEmpty.

isMark matters whenever you add your own spacing: a combining mark must stay on top of its base glyph, so never insert tracking (or a line break) between a base and its mark:

@font "Inter";
let styles = ${ font-family: Inter; font-size: 48; };
// Decomposed "é": "e" followed by the combining acute U+0301 (code point 769)
let glyphs = PathBlock.fromGlyph("é", styles);
log(glyphs[1].isMark);      // true
log(glyphs[1].codePoint);   // 769

let tracking = 6;
let x = 10;
for (g in glyphs) {
  M x 60
  g.draw()
  x = calc(x + g.advanceWidth);
  if (!g.isMark) {
    x = calc(x + tracking);  // letter-space after base glyphs only —
  }                          // never between a base and its combining mark
}

Scripts and Unicode notes

  • Newlines are universal. Every script — Latin, Arabic, Hangul, CJK, Devanagari — uses the same Unicode line-break characters, so isNewline works identically for all of them. (One deliberate exclusion: the rare legacy NEL character U+0085 is not isNewline — it is not isWhitespace either, and the three classes always partition isWhitespace exactly.)
  • Spaces are more than U+0020. isSpace also covers the no-break space, the ideographic (full-width) space U+3000 used in CJK text, and the typographic en/em/thin spaces — so CJK spacing works without special cases. Note that Chinese, Japanese, and Thai text does not separate words with spaces at all; space-based word handling only applies to scripts that use spaces.
  • Zero-width space is not whitespace. U+200B, the break-opportunity character often used in CJK and Thai text, is a format character: every is* member here is false for it (it renders as an invisible zero-advance glyph). Detect it with g.codePoint == 8203 if your layout should treat it as a break opportunity.
  • Combining marks overlay, they don't advance. Characters like Arabic harakat, Hebrew niqqud, Thai vowel signs, or a decomposed accent (e + ◌́) are combining marks: they render on top of the previous base glyph and typically have little or no advanceWidth. Use isMark to detect them and keep them attached to the preceding glyph (see the tracking example above).
  • No contextual shaping. fromGlyph looks up one glyph per character, so scripts that reshape letters by position — Arabic's isolated/initial/medial/final forms — render each letter in its isolated form. Full shaping is outside fromGlyph's per-character model.
  • codePoint is the escape hatch. For any classification not covered above, compare code points directly, e.g. if (g.codePoint == 12288) { … } to detect the ideographic space U+3000 (Pathogen number literals are decimal, so write the code point in decimal).

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.

Non-Latin text and missing glyphs

fromGlyph handles any script the font provides glyphs for — Hangul, Cyrillic, Greek, kana, and so on:

@font "Nanum Gothic";
let styles = ${ font-family: "Nanum Gothic"; font-size: 48; };
let glyphs = PathBlock.fromGlyph("안녕하세요", styles);

In the playground, the extra script subsets are fetched automatically on the first compile that needs them (see the @font directive above).

When a character has no glyph in the font at all — for example Hangul text with a Latin-only family, or an emoji — the glyph renders as the font's placeholder box and a warning is logged to the console pane:

[warn] Font 'Inter' (weight 400) has no loaded glyph for: 한 — rendered as placeholder boxes

The program still compiles; the warning tells you the font itself lacks those characters, so switch to a family that covers the script. TextBlock.toPathBlock() reports missing glyphs the same way.

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]

Segment Labels & Corner Suffixes

Segment labels and corner suffixes let you annotate a path at the point you define it. Two small clauses attach to any path command:

  • as gives a command (or the vertex it lands on) a name, so you can look it up later by that name instead of by a fragile numeric index.
  • with attaches a corner operation — a fillet or chamfer — to the joint the command creates, rounding or cutting it without restructuring your code.

Both clauses read left to right like English and work everywhere path commands do: inside @{ } path blocks, inside layer('name').apply { } blocks, and at the top level.

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

M 10 10
h 60 as segment('top');
v 40 with fillet(8) as endpoint('corner');
h -60;

Here top names the first horizontal edge, the vertical edge rounds the corner where it meets the top with an 8-unit fillet, and corner names the vertex at the end of that edge. Later code can ask the path for segment('top'), point('corner'), or vertex('corner') by name.

Syntax

A path command may carry an optional with clause and an optional as clause, in that order:

<command> <args> [with <corner-op>] [as <label> (, <label>)*] ;

Rules:

  • The with clause comes before the as clause.
  • At most one with clause and one as clause per command.
  • Only the as clause takes a comma-separated list — a single command can carry several labels (for example both a segment name and an endpoint name).
  • The trailing ; is optional on path commands, exactly as it is on plain commands.
// all valid
h 60 as segment('top');
v 40 with fillet(8);
v 40 with fillet(8) as endpoint('corner');
h -60 as segment('base'), endpoint('base-end');

The same clauses attach to statement functions — stdlib generators like circle, rect, and polygon — which do end with a semicolon:

circle(0, 0, 20) as segment('rim');
rect(0, 0, 40, 20) as segment('panel'), endpoint('panel-end');

A statement function can emit many commands. The as segment(...) label names the whole range those commands produce, so segment('rim') returns the entire circle.

Labels

Labels name parts of a path so you can query them later. There are two kinds, matching the two things a path is made of — its edges and its vertices.

as segment('name')

Names the command (or command range) itself. Query it with segment('name') to get a sub-path you can sample, measure, and decorate.

h 60 as segment('lid');
circle(0, 0, 20) as segment('rim');

as endpoint('name')

Names the vertex the command lands on — the point at its end. Query it two ways: point('name') for the coordinate (useful as a drawTo target), and vertex('name') for a handle that can round or cut the corner.

v 40 as endpoint('shoulder');

Both at once

Because the as clause takes a list, one command can name both its edge and its ending vertex:

h -60 as segment('base'), endpoint('base-end');

Label names

Label names are identifier-shaped: letters, digits, -, and _, starting with a letter. Everything else — including ., :, and whitespace — is rejected at compile time. The punctuation space belongs to the query language: . is the seam-namespace delimiter below, and : introduces query pseudo-selectors.

Two special forms exist for segment labels:

  • cut is reserved. Healed seam edges created by cut() carry the label cut automatically; authoring it yourself is a compile error, because your geometry would fuse indistinguishably into the seam group.
  • cut.<name> is the explicit opt-in. Labeling your own geometry as segment('cut.rim') deliberately joins it to the seam namespace: the umbrella query segmentAll('cut') picks it up alongside the real seams, and segmentAll('cut.rim') still addresses it on its own. This is the only place . may appear in an authored label.

Endpoint labels take the plain identifier form only.

Group labels and computed labels

Labels don't have to be unique, so the natural way to label loop-generated geometry is to reuse one name and query the group with segmentAll:

define ViewBox(0, 0, 200, 120);
define default PathLayer('ribs') ${ stroke: #333; stroke-width: 2; fill: none; }

M 10 60
for (i in 0..5) {
  v -30 as segment('rib');
  v 30;
  h 30;
}

All six ribs share the name rib; segmentAll('rib') returns them in authoring order. When members need distinct names — say, to look one up individually — a label name is an expression, so ${ } interpolation works:

v -30 as segment(`rib-${i}`);

That produces rib-0 through rib-5 (ranges are inclusive), each individually addressable with the singular segment('rib-3').

Corner Suffixes

The with clause attaches a corner operation to the joint a command creates — the vertex between the previous command and this one. It is the definition-site spelling of the fillet and chamfer methods: instead of rounding a corner after the fact by its index, you round it right where you draw it.

with fillet(radius)

Rounds the joint with a circular arc of the given radius, trimming the two adjacent edges so the arc sits tangent to both:

define ViewBox(0, 0, 100, 100);
define default PathLayer('box') ${ stroke: #333; stroke-width: 2; fill: none; }

M 20 20
h 60;
v 60 with fillet(10);
h -60;
z;

The fillet trims the incoming and outgoing edges exactly as .fillet() does — the rounded result is shorter along both edges than the sharp corner would be.

with chamfer(distance) / with chamfer(d1, d2)

Cuts the joint with a straight bevel. One distance chamfers symmetrically; two distances trim the incoming and outgoing edges independently:

v 60 with chamfer(10);
v 60 with chamfer(6, 14);

Recorded at definition, applied at finalization

Corner suffixes do not rewrite the command as you write it. The operation is recorded on the joint and applied when the path is finalized — when the @{ } block closes, or when a layer is emitted. Two consequences follow:

  • Your authored geometry is preserved. The pen still moves by the extents you wrote; ctx.position mid-path reflects the sharp corner, not the trimmed one. The trimming happens at the end, so the cursor math you do between commands stays predictable.
  • Labels survive the operation. A segment or endpoint label on a filleted command still resolves after the corner is rounded. On PathBlock values and layer queries, endpoint labels name the authored vertex: if a corner op trims the corner a label sits on, point('name') still answers with the sharp corner you wrote, not the trimmed edge. On a ProjectedPath (the result of project()/draw()/drawTo()), queries answer the projected, finalized geometry — a filleted corner's labeled point is the trimmed tangent point.

Junction support

with fillet(...) and with ellipticalFillet(...) follow the same rules as the fillet methods: tangent-based rounding that skips tangent-collinear junctions; junctions involving curves follow the same tangent math. with chamfer(...) works at junctions between all command types — lines, curves, and arcs — matching chamfer. If a radius or chamfer distance is larger than an adjacent edge, it is clamped to the edge length and a warning is logged.

Querying Labels

Labels exist to be looked up. A path with labels answers three questions by name, on both PathBlock and ProjectedPath values:

Query Returns Use for
pb.segment('name') PathBlock The first segment matching the name
pb.segmentAll('name') array of PathBlock Every segment sharing the name, in authoring order
pb.point('name') Point The first named vertex — a drawTo/layout target
pb.pointAll('name') array of Point Every vertex sharing the name
pb.vertex('name') vertex handle Rounding or cutting the first named corner
pb.vertexAll('name') array of handles Every corner sharing the name

The pairing follows the model you already know from the DOM: segment is querySelector (first match), segmentAll is querySelectorAll (all matches). Labels don't have to be unique — a name shared by several statements forms a group, which makes loops natural:

let comb = @{
  for (i in 0..4) {
    v -20 as segment('tooth');
    v 20;
    h 12;
  }
};

// five teeth, one name, no index bookkeeping
for (tooth in comb.segmentAll('tooth')) {
  log(tooth.length);
}

Consecutive same-labeled statements merge into one segment; runs separated by other commands are distinct group members. Singular queries error when the name matches nothing (listing what the path has); the All queries return an empty array instead, so they loop safely over names that might not exist.

Query pseudo-selectors

A segment query can carry one CSS-style pseudo-selector after the name — possible because label names can never contain :, so the suffix is unambiguous:

Query Returns
segmentAll('tooth:atomic') Every matching drawing command as its own block — the merge undone (pure moves are skipped; they carry no geometry)
segment('tooth:first') The first run of the group (same as the bare singular)
segment('tooth:last') The last run of the group
segment('tooth:nth(2)') The run at index 2 — 0-indexed, matching the language's arrays (CSS counts from 1; Pathogen doesn't)

:atomic is the escape hatch for the merge rule: a stdlib call like circle(0, 0, 40) as segment('rim') labels everything it draws as one run, and segmentAll('rim:atomic') hands back the individual arcs — no subPath surgery at guessed fractions. The position pseudos select whole runs from a group; on the All form they return an array of at most one element.

let wheel = @{
  circle(0, 0, 40) as segment('rim');
};
let arcs = wheel.segmentAll('rim:atomic');   // [arc1, arc2]
let firstArc = wheel.segment('rim:nth(0)');

Rules:

  • One pseudo per query. The available set is :atomic, :first, :last, :nth(k) — anything else (or a chain) is an error listing the options.
  • Pseudos apply after matching and merging, so they compose with the seam namespace: segmentAll('cut:first') selects from the merged umbrella runs, segmentAll('cut.k0:atomic') decomposes one knife's seams command by command.
  • Segment queries only — point/pointAll/vertex/vertexAll reject names containing : with a pointer here (vertices don't merge, so there is nothing for a pseudo to do).
  • An out-of-range :nth(k) behaves like an unmatched name: the All form returns [], the singular form errors saying how many runs the group has.

segment('name') → PathBlock

Returns the labeled command range as a full PathBlock, with every geometry method — get, tangent, normal, partition, boundingBox, and the rest:

let outline = @{
  h 60 as segment('lid');
  v 40;
  h -60;
};

// decorate evenly along just the lid
let lid = outline.segment('lid');
for (op in lid.partition(6)) {
  circle(op.point.x, op.point.y, 2);
}

point('name') → Point

Returns the coordinate of a named vertex — ready to use as a drawTo anchor so shapes align to a named location instead of a hand-computed one:

let frame = @{
  h 80 as endpoint('hinge');
  v 50;
  h -80;
};
let proj = frame.project(10, 10);

let tab = @{ circle(0, 0, 6); };
tab.drawTo(proj.point('hinge').x, proj.point('hinge').y);

On a ProjectedPath, point('name') returns absolute coordinates; on an unprojected PathBlock the coordinates are relative to the block origin.

vertex('name') → vertex handle

Returns a handle for the named corner. The handle exposes .fillet(radius), .chamfer(distance) / .chamfer(d1, d2), and .ellipticalFillet(rx, ry), each returning a new path with that corner operation applied — the name-based counterpart to filletAtVertex that never breaks when you add a command earlier in the path:

let box = @{
  h 60 as endpoint('corner');
  v 40;
  h -60;
  z;
};

let rounded = box.vertex('corner').fillet(8);
rounded.drawTo(10, 10);

This is exactly why labels beat indices: inserting a command above corner shifts every vertex index, but vertex('corner') still points at the same joint.

Querying layers

Layers built with apply { } are queryable by the same names. layer('name').segment('label') returns the labeled range in the layer's absolute coordinates, and .point(...) / .vertex(...) work the same way:

define ViewBox(0, 0, 200, 120);
define default PathLayer('road') ${ stroke: #333; stroke-width: 2; fill: none; }
define PathLayer('markers') ${ fill: #cc0000; }

layer('road').apply {
  M 10 60
  h 180 as segment('main');
}

// space dots along the named road segment
layer('markers').apply {
  for (op in layer('road').segment('main').partition(9)) {
    circle(op.point.x, op.point.y, 3);
  }
}

Labels Survive Derived Paths

Operations that produce a new PathBlock or ProjectedPath carry your labels with them. A path labeled as segment('rim') still answers segment('rim') after it has been transformed, combined, or cut:

  • Transforms: reverse, offset, mirror, scale, rotate, rotateAtVertexIndex, subPath
  • Corner shaping: fillet, chamfer, ellipticalFillet and their AtVertex variants
  • Set operations: union, difference, intersection, xor — labels from both operands coexist in the result
  • Cutting: cut() — pieces keep the subject's labels on their surviving boundary fragments

Cut seams are labeled for you. Every healed edge a cut creates — the cutter's strokes where they sealed a piece shut, a cookie cutter's stamped boundary (in both the stamped piece and the hole it left), and any bridging segments — carries the segment label cut. When the cutter edge itself was named — as segment('valley') on the knife — the seams it heals carry the sub-label cut.valley instead, so each knife's seams are addressable on their own. That makes the seams queryable like any other group:

let pieces = shape.cut(knife);
for ([p, i] in pieces) {
  // Project the piece to where it is drawn — the projected form answers
  // queries in absolute coordinates (segment() sub-blocks are rebased).
  let placed = p.project(20, 20);
  for (seam in placed.segmentAll('cut')) {
    for (op in seam.partition(4)) {
      circle(op.point.x, op.point.y, 1.5);
    }
  }
}

The umbrella query segmentAll('cut') returns every seam — plain and sub-labeled alike, adjacent seam commands merged into runs regardless of which knife made them — so seam-agnostic code never has to know the knives' names. A sub-label query like segmentAll('cut.valley') is exact: it answers only that knife's seams, as their own runs. And to pull your own geometry into the seam group — say a rim that the same came loop should stroke — label it with the explicit opt-in form as segment('cut.rim'); bare 'cut' is reserved (see Label names).

Note that per-piece seam queries answer each interior seam twice — once from each adjacent piece. When you want each physical seam once (fold lines, came), use pieces.seams() on the array cut() returns.

What to know about derived labels:

  • Runs merge. Adjacent commands with the same label merge into one queryable run, so two seam edges that meet end-to-end come back from segmentAll('cut') as a single run, not two.
  • Queries answer finalized geometry. A derived block's point()/vertex() answer the geometry as it exists after the operation — e.g. the trimmed corner after a fillet, not the authored sharp one (the authored-position preference applies only to the original block).
  • Reversal moves endpoint labels correctly — an as endpoint(...) name stays on its vertex when a path is reversed. One exception: on an open path, an endpoint label on the final vertex does not survive reverse() (that vertex becomes the start point, which carries no command metadata).
  • Corner-op suffixes don't carry. A pending with fillet(...) is consumed by the block it was written in; it never re-applies on a derived path.
  • Labels on m commands survive only point-mapping transforms. A label on a move command (e.g. m 10 10 as endpoint('start')) carries through mirror(), scale(), rotate(), rotateAtVertexIndex(), and offset(), which map commands one-to-one — but reverse(), subPath(), the boolean operations, and cut() rebuild the path from its drawing commands and drop moves along the way, taking their labels with them. Prefer labeling a drawing command.
  • Excluded: variableOffset and compoundVariableOffset resample the geometry entirely, so there is no correspondence to carry labels through — their results are unlabeled.
  • The cutter's own segment labels propagate into the seam namespace: a knife edge as segment('valley') heals into seams labeled cut.valley (plain cut when the knife edge is unlabeled). The cutter's endpoint labels do not propagate — cut endpoints land on junction points shared by several pieces, so no single edge owns them.

Errors & Notes

  • Shared labels form groups. Label names do not have to be unique: reusing segment('rib') across several statements creates a group, and segmentAll('rib') returns every member (singular segment('rib') returns the first, querySelector-style). The same applies to endpoint labels via pointAll/vertexAll. Segment and endpoint labels are separate namespaces, so segment('x') and endpoint('x') may coexist (they answer different queries).
  • with needs a previous joint. A corner suffix rounds the joint between the previous command and this one, so it needs a previous command in the current subpath. with fillet(...) on the first command of a block, or on the first command after an m/M that opens a new subpath, is a compile error — there is no corner to operate on. (This mirrors the "no previous heading" error from tangentArc.)
  • Clause ordering. with must precede as, each may appear at most once, and only as takes a comma list. as segment('x') with fillet(5) (wrong order) and two with clauses on one command are both errors.
  • Label-name validation. A label that isn't identifier-shaped — punctuation, whitespace, a leading digit — is a compile error naming the rule, as is the reserved bare 'cut'. The cut.<name> opt-in is segment-only; an endpoint label may not use it. Queries stay lenient: any string can be queried (unknown names behave as described below) — only authoring is validated.
  • Pseudo-selector errors. An unknown pseudo ('rib:frist') or a chained pseudo ('rib:first:atomic') errors listing the available set (:atomic, :first, :last, :nth(k)). A pseudo on a point/vertex query errors pointing at segment queries. :nth(k) out of range errors on the singular form (saying how many runs exist) and returns [] on the All form.
  • Unknown labels. A singular query for a name that was never defined — pb.segment('nope') — is an error that lists the labels the path actually has, so a typo tells you what was available. The All queries return an empty array instead of erroring, matching querySelectorAll, so for (x in pb.segmentAll('maybe')) is safe without a guard.
  • Vertex corner ops: PathBlocks for now. vertex('name').fillet(...) and its siblings apply on PathBlock and ProjectedPath sources. On a layer vertex handle they report that corner operations are not supported on layers yet — layer and projected segment/point queries work fully; only the vertex-handle corner operations are deferred.

Variable Offset

variableOffset and compoundVariableOffset are path block methods that trace a new path alongside an existing one, using a gradient-stop-like syntax. You place stops at positions along a reference path (the spine) and give each stop a perpendicular offset distance; the resulting points are connected into a smooth (or sharp) curve whose joins you control per-stop.

Where the existing offset(distance) produces a uniform parallel curve at one fixed distance, variableOffset lets the distance vary from stop to stop — and lets you choose the curve continuity (sharp corners, smooth tangents, or flowing curvature) at each stop. Reach for it when you want tapered or calligraphic strokes, ribbons and casings that follow a path, or banded flow-field effects.

The model: the spine is a rail

Each stop samples the spine at a position, then steps perpendicular to the spine by the offset distance to produce a point. The new path is built through those points. The spine positions and orients the points — its own segments do not appear in the output. Think of the spine as a rail that places and aims your control points, not as a shape the result must hug.

Simple offset — an open path

variableOffset returns a new open path block. Call it with a block that receives two parameters: go (the offset builder) and pb (a read-only reference to the spine).

let spine = @{
  h 20
  tangentArc(20, 45deg);
  tangentLine(20);
  tangentArc(20, 45deg);
  v 20
};

let edge = spine.variableOffset() {|go, pb|
  go.stop(10%, 5,  CurveContinuity.G1);
  go.stop(50%, 12, CurveContinuity.G2);
  go.stop(90%, 20, CurveContinuity.G1);
};

The path spans the first stop to the last stop. To add a lead-in or lead-out, compose path blocks with the << concatenation operator — e.g. @{ m -10 0 } << edge — rather than adding endpoints inside the block. Concatenation returns a new path block that does not carry anchor; if you need it, read it before composing.

A reusable builder applies with the same operator: writing the call without a trailing block and following it with << supplies the builder as a worker function

let mk = {|go, pb|
  go.stop(10%, 5,  CurveContinuity.G1);
  go.stop(90%, 20, CurveContinuity.G1);
};
let edge = spine.variableOffset() << mk;

The rule separating the two meanings is the trailing block: with a block present the call is already complete, so ... {|go, pb| ...} << other concatenates path blocks; with empty parentheses and no block, << provides the builder. One composition trap to know: << is left-associative, so @{ m -10 0 } << spine.variableOffset() << mk groups the bare builder call with the lead-in first and errors ("requires a block or a << worker"). Apply the worker in its own step, then compose:

let edge = spine.variableOffset() << mk;
let full = @{ m -10 0 } << edge;

go.stop(time, normalOffset, continuity)

Argument Meaning
time Position along the spine as an arc-length fraction: 0 = start, 1 (or 100%) = end. Percent literals are sugar — 10% is exactly 0.1.
normalOffset Perpendicular distance from the spine at that position. Positive and negative offset to opposite sides (same sign convention as offset()).
continuity A CurveContinuity value controlling the join at this point.

Stops are visited in the order written. time must be in [0, 1].

Curve continuity

CurveContinuity selects how the curve behaves at each stop — the trade-off between sharp and smooth:

Value Join Result
CurveContinuity.G0 position only a corner — the curve meets the point but bends sharply
CurveContinuity.G1 + matching tangent direction no kink — the curve flows through with a continuous heading
CurveContinuity.G2 + matching curvature seamless flow — the rate of bending is continuous, the smoothest option

A G0 stop breaks the curve into separate smooth spans on either side. A run of G1/G2 stops is built as a single spline through them; G2 runs solve for curvature continuity across the whole run. (Keep continuity uniform within a run — mixing G1 and G2 in the same run is treated as G2 for now.)

Endpoint tangents

By default, the curve leaves its first and last points along the spine's own direction at those stops — a sensible, zero-configuration default. To control an endpoint explicitly, supply a PolarVector handle:

let edge = spine.variableOffset() {|go, pb|
  go.startTangent(PolarVector(-90deg, 8));       // leave straight up (y-down: -90° = up), tension 8
  go.stop(10%, 5,  CurveContinuity.G2);
  go.stop(90%, 20, CurveContinuity.G2);
  go.endTangent(PolarVector(0deg, 6).turn(pb.tangent(90%).angle));  // relative to the spine
};

The PolarVector angle is absolute; its distance encodes tension (how firmly the curve pulls toward the handle). For a spine-relative angle, rotate by the spine tangent with .turn(pb.tangent(time).angle). A tangent handle applies only to a G1/G2 endpoint — a G0 endpoint is a corner with no tangent to set.

Angles are radians. Sampling queries (pb.tangent, pb.normal) return angles in radians, and Pathogen's y-axis points down — so -π/2 (i.e. -90deg) points up. Angle literals in code carry a unit suffix, e.g. 90deg, 45deg.

Compound offset — a closeable ribbon

compoundVariableOffset places two profiles — one on each side of the spine — and can close them into a filled ribbon with end caps. Each stop takes two offset/continuity pairs:

let ribbon = spine.compoundVariableOffset() {|go, pb|
  go.startCap(Cap.round());
  go.stop(10%,  5, CurveContinuity.G1,  -10, CurveContinuity.G1);
  go.stop(50%, 10, CurveContinuity.G2,   -5, CurveContinuity.G2);
  go.stop(90%, 20, CurveContinuity.G1,  -20, CurveContinuity.G1);
  go.endCap(Cap.tapered(12, CurveContinuity.G2));
};

compoundVariableOffset takes a worker the same way as the simple form — spine.compoundVariableOffset() << mk — which is how a ribbon builder gets reused across many layers.

go.stop(time, offset1, continuity1, offset2, continuity2) (compound)

Argument Meaning
time Position along the spine (01 or percent), as in the simple form.
offset1, continuity1 Offset distance and join for profile 1.
offset2, continuity2 Offset distance and join for profile 2.

With both caps present the result is a closed path, assembled as: profile 1 forward → end cap → profile 2 back → start cap → close. Omit a cap and that end stays open — omitting both yields two separate, unconnected profiles.

The offset signs decide which side each profile lands on. Opposite signs (as above) put the profiles on opposite sides of the spine, straddling it; same-sign offsets put both profiles on the same side, producing a detached band that floats beside the spine without touching it — a legal and useful configuration (an offset ribbon riding one side of a curve). The band's width at each stop is the difference between the two offsets.

End caps

Caps are constructor values — Pathogen's convention is enums for parameter-less choices (like CurveContinuity), constructor functions for parameterized ones. Given the two profile endpoints at the capped stop:

Cap Geometry
Cap.butt() a straight line between the two endpoints
Cap.round() a semicircle bulging outward
Cap.elliptical(projection) a half-ellipse projecting projection units outward
Cap.tapered(length, continuity?) a point length units out; the optional CurveContinuity smooths the taper flanks (G0 = sharp point)

Querying the spine — pb

The second block parameter, pb, is a read-only handle to the spine, mirroring the path block sampling API so it behaves exactly as you already expect:

Member Returns
pb.length total arc length of the spine (a number)
pb.get(time) the point on the spine at arc-length fraction time
pb.tangent(time) { point, angle } — the tangent at time; angle in radians
pb.normal(time) { point, angle } — the normal at time; angle in radians
pb.vertices the spine's vertices as a list of points

Placement — origin normalization and anchor

An offset places freely by default and re-registers on demand: the result is normalized so you can drop it anywhere, and anchor puts it back exactly where the spine had it.

Both forms return a path block normalized to its own first point: the result's local origin (0,0) is the first point of the curve — the spine sampled at the first stop's time, stepped out along the normal by that stop's offset (for compoundVariableOffset, by the first stop's offset1: the ribbon's traversal begins on profile 1). The spine's own position is not carried into the result. draw() therefore lands the curve's first point on the current cursor, and drawTo(x, y) lands it on (x, y) — for a normalized block the two are interchangeable.

That is the right behavior for placing a ribbon freely, but when the offset should sit on top of its spine — tracing a glyph contour, casing an existing path — you need the subtracted position back. The result carries it as anchor: a point, in the spine's coordinate space, holding exactly the translation the normalization removed.

"The spine's coordinate space" is whatever coordinates the spine's own commands carry. A @{ } block begins at its own (0,0), so anchor is relative to wherever you land that block's first point. A glyph contour from .contours keeps glyph-space coordinates, so anchor is relative to the glyph origin — which is why the example below adds the glyph's (x, y) rather than any per-contour position.

edge.anchor is the curve's first point, expressed in the spine's coordinates:

Form anchor is
variableOffset the spine sampled at the first stop's time, stepped along the normal by that stop's offset
compoundVariableOffset profile 1's first knot — the same sample, stepped by the first stop's offset1. offset2 plays no part

Add anchor to the spine's own placement and the curve registers exactly, whatever the stop times and offsets:

@font "Baumans" 400;
let glyphStyles = ${ font-family: "Baumans"; font-size: 120; };
let glyphSet = PathBlock.fromGlyph('So', glyphStyles);
let glyphLayer = PathLayer('glyphs') ${ fill: none; stroke: #999; };
let ribbonLayer = PathLayer('ribbon') ${ fill: #c00; stroke: none; };

let x = 20;
let y = 120;
for (glyph in glyphSet) {
  glyphLayer.apply {
    M x y
    glyph.draw();
  }

  for (contour in glyph.contours) {
    let ribbon = contour.compoundVariableOffset() {|go, pb|
      go.startCap(Cap.tapered(10, CurveContinuity.G0));
      go.stop(10%, 1, CurveContinuity.G2, -1, CurveContinuity.G2);
      go.stop(50%, 8, CurveContinuity.G2, -8, CurveContinuity.G2);
      go.stop(90%, 1, CurveContinuity.G2, -1, CurveContinuity.G2);
      go.endCap(Cap.tapered(10, CurveContinuity.G0));
    };

    ribbonLayer.apply {
      M calc(x + ribbon.anchor.x) calc(y + ribbon.anchor.y)
      ribbon.draw();
    }
  }
  x = calc(x + glyph.advanceWidth + 20);
}

The M + draw() pair for the ribbon mirrors how the glyph itself is placed — both anchor a block's coordinates at a chosen origin. The o contributes two contours (outline and counter), and each gets its own correctly-registered ribbon.

Sampling the spine yourself (contour.get(10%)) is close but off by the first stop's offset along the normal — and it silently drifts if you later edit the first go.stop(...). anchor always matches the built curve.

anchor lives only on the variableOffset / compoundVariableOffset result itself. Composing or transforming it (@{ m -10 0 } << edge, .reverse(), .offset()) produces a new path block that does not carry it — read let a = edge.anchor; before composing.

Errors

The compiler rejects:

  • time outside [0, 1] — stops must fall on the spine.
  • Decreasing time between stops — stops are visited in order along the spine; a later stop cannot sit before an earlier one.
  • A zero or negative Cap.elliptical projection — use Cap.butt() for a flat end.
  • An unknown continuity value — use CurveContinuity.G0, .G1, or .G2.
  • A cap on the simple formstartCap/endCap apply only to compoundVariableOffset.
  • A tangent handle on the compound formstartTangent/endTangent apply only to the simple variableOffset; a compound ribbon's ends are shaped by startCap/endCap.
  • Fewer than two stops — a path needs at least two points, so both forms require at least two go.stop(...) calls.
  • anchor on a block that is not a variableOffset / compoundVariableOffset result — including one produced by composing or transforming a result (<<, .reverse(), .offset()). Read anchor off the result before composing.

Self-intersecting output is not an error: if dense stops or extreme offsets make the curve cross itself, Pathogen emits the true curve as-is rather than silently reshaping your geometry.

Debug mode: variableOffset() and compoundVariableOffset() are not yet supported in the CLI's --annotated debug mode. They work normally everywhere else — CLI compilation, the playground, and the VS Code preview.

TextBlock

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

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

Quick Overview

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

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

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

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

Syntax

TextBlock uses the &{ } sigil:

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

Inside a text block you can use:

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

Not allowed inside text blocks:

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

Types

TextBlockValue

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

ProjectedTextValue

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

Methods

TextBlockValue

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

ProjectedTextValue

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

Properties

TextBlockValue

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

ProjectedTextValue

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

Style Merging

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

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

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

BBoxAnchor Enum

The BBoxAnchor enum provides named positions on a bounding box:

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

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

Font Metrics

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

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

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

Font metrics respect:

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

Polar Projection

Place text along a polar vector with anchor alignment:

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

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

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

Intersection Detection

Check if text bounding boxes overlap to avoid label collisions:

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

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

.intersects() accepts:

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

Text to Path Conversion

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

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

Requirements:

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

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

let pb = tb.toPathBlock();

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

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

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

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

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

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

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

Code Snippet Blocks

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

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

Arguments:

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

Returns: LayerReference to a GroupLayer containing:

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

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

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

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

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

Escaping ${ in Code Text

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

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

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

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

Syntax Highlighting Palette

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

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

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

Examples

Label placement around a shape

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

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

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

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

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

Dynamic labels with collision avoidance

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

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

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

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

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

Color Type

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

Color Literals

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

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

Color literals support method chaining via parentheses:

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

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

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

CSS Color Function Literals

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

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

Method chaining works directly:

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

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

Constructor

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

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

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

Properties

Read-only properties for inspecting color values:

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

Methods

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

Lightness

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

Saturation

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

Alpha

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

Flattening

flatten(background?) merges a translucent color down onto a background color, the way an image editor flattens layers — the result is the color you actually see when the translucent color is drawn over that background. Reach for it when transparency has to go: print and PDF export is the canonical case — paper has no alpha channel, so a tint designed as a translucent overlay becomes its equivalent opaque ink.

let ink = (#ff0000).alpha(0.5);   // 50%-opaque red
let onWhite = ink.flatten();      // background defaults to white
let onBlack = ink.flatten(#000);  // any Color works as the background
log(onWhite.hex);  // ≈#ff8080 — half red, half white, per channel
log(onWhite.a);    // 1 — transparency is gone

Compositing uses the standard source-over formula on gamma-encoded sRGB channels, matching how a browser paints a translucent color over a solid background with normal blending — so for in-gamut colors flatten() returns the color you already see on screen, with the transparency baked in. Because the math runs in sRGB, a color outside the sRGB gamut is clipped into it, even when it is already opaque.

Flattening an already-opaque, in-gamut color returns it unchanged, and flattening a fully transparent color returns the background. The background may itself be translucent: the result then keeps the correctly composited alpha (out = srcAlpha + bgAlpha * (1 - srcAlpha)); flattening onto any opaque background always produces a fully opaque color.

Theme-dynamic colors are rejected: calling flatten() on (or with) a color backed by CSSVar(...) or Color.lightDark(...) is an error, because CSS has no equivalent of alpha compositing — the result could no longer follow the theme. Flatten the underlying static color instead.

Hue

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

hueShift takes degrees when given a bare number — so you can write the angle in whatever unit the surrounding code already uses, without hand-converting. An Angle value (anything written with a deg, rad, or pi suffix, including calc() arithmetic over angle-suffixed literals) is converted to degrees exactly, no matter how it reaches the call:

let c = Color('#e63946');
let quarterTurn = c.hueShift(90);    // 90° — bare numbers are degrees
let sameByDeg = c.hueShift(90deg);   // 90° — angle units auto-convert
let sameByPi = c.hueShift(0.5pi);    // 90° — π/2 radians
// A hue wheel in nine swatches — the 2pi literal makes this an angle
let c = Color('#e63946');
for (i in 1..9) {
  let swatch = PathLayer(`shift-${i}`) ${
    stroke: none;
    fill: c.hueShift(calc(i / 9 * 2pi));
  };
  swatch.apply { rect(0, calc(i * 24), 20, 20); }
}

An angle is an angle wherever it flows. calc(i / 9 * 2pi) is an Angle value, and it stays one through a let, an array, a function call, or an angle-preserving standard-library function like clamp, lerp, or randomRange — hoisting it into a variable does not change the shift. Only a genuinely bare number is read as degrees — and results of angle-consuming functions are bare numbers, so calc(sin(t) * 180) is degrees.

Behavior change: angle units used to be consumed at the literal — let t = 0.5pi; c.hueShift(t) shifted 1.57°, not 90°. Angles now survive variables, so that program shifts 90°. They also survive angle-preserving standard-library calls: c.hueShift(randomRange(-0.5pi, 0.5pi)) used to shift by a near-invisible ±1.57° (the angle range was flattened to bare radians, read as degrees) and now shifts within ±90°.

let c = Color('#e63946');
let sweep = calc(6 / 9 * 2pi);           // an Angle — 240°
let a = c.hueShift(sweep);               // 240° — angle-ness survives the let
let b = c.hueShift(deg(sweep));          // 240° — deg() returns a plain number of degrees
let d = c.hueShift(calc(sin(1) * 180));  // ≈151° — sin() returns a plain number, read as degrees
let e = c.hueShift(randomRange(-0.5pi, 0.5pi));  // random shift in ±90° — the angle survives randomRange

The same rules apply to analogous() and splitComplementary(), and to colors backed by CSSVar(...) — the emitted CSS hue expression uses degrees. Color(L, C, H) follows suit: a bare H is degrees and an Angle H auto-converts, so Color(0.6, 0.15, 90deg) stores a hue of 90. The .hue property returns a plain number in degrees.

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 and splitComplementary take an angle argument (triadic and tetradic are fixed at 120° and 90° spacing). Angle arguments are in degrees for bare numbers, with the same auto-conversion as hueShift: analogous(30deg) and analogous(30) are equivalent.

.analogous(angle?)

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

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

.triadic()

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

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

.tetradic()

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

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

.splitComplementary(angle?)

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

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

Using Harmonies

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

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

Complete Example

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

let dsx = 260;
let dsp = 70;

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

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

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

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

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

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

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

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

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

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

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

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

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

Static Methods

Color.mix(c1, c2, ratio)

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

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

Color.palette(color, n)

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

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

Color.palette(c1, c2, n)

Generate n evenly interpolated colors between two colors:

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

n must be an integer >= 2.

Color.lightDark(light, dark)

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

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

Works with CSSVar-backed colors for full customizability:

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

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

@property Declarations

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

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

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

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

Style Block Auto-Conversion

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

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

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

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

Template Literals

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

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

Roundtrip Fidelity

Standard CSS colors roundtrip exactly:

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

Named Colors

All 148 CSS named colors are supported:

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

Named color lookup is case-insensitive.

Gradients

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

LinearGradient

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

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

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

RadialGradient

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

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

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

Trailing Block Syntax

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

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

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

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

Using Gradients in Styles

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

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

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

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

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

Gradient Attributes

Set optional attributes via property assignment after creation:

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

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

Color Interpolation

Control how colors transition between stops using the interpolation property.

OKLCh Interpolation

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

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

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

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

linearRGB Interpolation

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

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

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

Default (sRGB)

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

Reactive Gradient Stops

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

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

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

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

Pattern Paint Server

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

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

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

Pattern Methods

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

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

Pattern Properties

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

Using Patterns in Styles

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

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

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

Pattern SVG Output

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

Conic Gradient

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

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

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

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

Conic Gradient Properties

Property Values Default
from Start angle — an Angle value, or a plain number in radians (a literal requires a unit: deg, rad, pi) 0rad (3 o'clock)
to End angle — same rule as from 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

Angle values are accepted from anywhere — a variable, an array element, or a function parameter carries its angle with it:

let start = 135deg;
gauge.from = start;      // OK — an Angle value stays an angle
gauge.from = rad(135);   // OK — rad() returns a plain number of radians

Plain computed numbers (like the rad(135) above) are accepted without unit checks and read as radians.

Partial Sweep

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

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

Direction

direction controls which way colors sweep within the arc:

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

Spread Modes

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

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

Inner Radius

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

gauge.innerRadius = 30;

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

Value Effect
'transparent' Hard cutoff — empty center (default)
'transparent-blend' Smooth blend from transparent at center to gradient at edge
'center' Smooth blend from first stop color at center to gradient at edge
Color(...) Smooth blend from custom color at center to gradient at edge
gauge.innerFill = 'transparent';        // hard donut hole (default)
gauge.innerFill = 'transparent-blend';  // soft transparent fade
gauge.innerFill = 'center';             // first-stop color, blends outward
gauge.innerFill = Color('#1a1a2e');     // custom color, blends outward

This is useful for donut-style gauges and ring charts. Inner radius rendering requires WebGPU, which is only available in the playground. The CLI wedge-path renderer ignores innerRadius and emits a warning when it is set.

// Ring gauge with transparent center and partial sweep
let ring = ConicGradient('ring', 100, 100) {|g|
  g.stop(0, Color('#2a9d8f'));
  g.stop(0.5, Color('#e9c46a'));
  g.stop(1, Color('#e63946'));
};
ring.from = 135deg;
ring.to = 405deg;
ring.innerRadius = 30;
ring.innerFill = 'transparent';  // donut hole

Rendering

Since SVG has no native conic gradient element, the output depends on the consumer:

  • CLI (--output-svg-file): Wedge-path SVG approximation wrapped in <pattern>. Each ~1° slice is an individual <path> element with an interpolated fill color.
  • Playground: Canvas 2D createConicGradient() → rendered to a PNG image → injected as <pattern><image/></pattern> for higher quality.

Both approaches are referenced via url(#id) in fill/stroke, identical to native gradients.

OKLCh Interpolation

Conic gradients support OKLCh interpolation via the shared interpolation and steps properties:

let smooth = ConicGradient('smooth', 100, 100) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(1, Color('#2a9d8f'));
};
smooth.interpolation = 'oklch';
smooth.steps = 15;

Conic Gradient CSS Variable Limitation

Conic gradients are rasterized at compile time (Canvas 2D in the playground, wedge-path approximation in the CLI). This means Color(CSSVar(...)) stops in conic gradients are baked out — the fallback color is extracted and used directly in the rasterized output.

Unlike linear and radial gradients, which use native SVG elements with live var() references, conic gradients will not update when CSS custom properties change at runtime.

Unfortunately, live-updating CSS variable colors is only available in the playground at this time. The compiler emits a warning when conic gradients contain CSSVar stops.

Conic Gradient Inheritance

Use .inherit(newId) to create child conic gradients. All conic-specific properties (from, to, direction, spread, innerRadius, innerFill) propagate to the child:

let child = wheel.inherit('child-wheel');
child.from = 90deg;

Gradient Inheritance

Create a new gradient that inherits stops and attributes from an existing one using .inherit(newId):

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

let rotated = base.inherit('rotated');
rotated.gradientTransform = 'rotate(90, 0.5, 0.5)';

The inherited gradient uses SVG's href attribute to reference the parent. It inherits all stops and attributes from the parent, and you can override specific attributes on the child. Inherited gradients with no stops of their own produce self-closing elements.

Property Access

Expression Returns
gradient.id The gradient's string ID
gradient.spreadMethod Current spreadMethod or undefined
gradient.gradientUnits Current gradientUnits or undefined
gradient.gradientTransform Current gradientTransform or undefined
gradient.interpolation Current interpolation mode or null
gradient.steps Current steps value or null
gradient.from Conic: start angle in radians (default 0)
gradient.to Conic: end angle in radians (default )
gradient.direction Conic: 'cw' or 'ccw' (default 'cw')
gradient.spread Conic: spread mode (default 'clamp')
gradient.innerRadius Conic: center plateau radius in pixels (default 0)
gradient.innerFill Conic: inner fill mode — 'transparent', 'transparent-blend', 'center', or Color value
pattern.id The pattern's string ID
pattern.patternUnits Current patternUnits or null
pattern.patternTransform Current patternTransform or null
pattern.patternContentUnits Current patternContentUnits or null

Dynamic Stop Generation

Use loops and expressions inside the trailing block for programmatic stops:

let ramp = LinearGradient('ramp', 0, 0, 1, 0) {|g|
  let colors = ['#e63946', '#f4a261', '#2a9d8f', '#264653', '#e9c46a'];
  for ([color, i] in colors) {
    g.stop(calc(i / 4), Color(color));
  }
};

Any statement valid in the language can appear inside the block — for loops, if statements, let bindings, function calls, etc.

SVG Output

The compiler produces gradient definitions in the <defs> section:

<defs>
  <linearGradient id="fade" x1="0" y1="0" x2="1" y2="1">
    <stop offset="0" stop-color="rgb(89.56% 22.41% 27.51%)"/>
    <stop offset="0.5" stop-color="rgb(95.69% 63.53% 38.04%)"/>
    <stop offset="1" stop-color="rgb(16.47% 61.57% 56.08%)"/>
  </linearGradient>
</defs>

Radial gradients use the <radialGradient> tag with cx, cy, r (and optionally fx, fy) attributes.

Inherited gradients use href:

<linearGradient id="rotated" href="#base" gradientTransform="rotate(90, 0.5, 0.5)"/>

Output Format

When using the JavaScript API, gradients appear in result.gradients:

const result = compile(`
  let g = LinearGradient('fade', 0, 0, 1, 1) {|g|
    g.stop(0, Color('#e63946'));
    g.stop(1, Color('#2a9d8f'));
  };
`);

// result.gradients:
// [
//   {
//     id: 'fade',
//     type: 'linear',
//     attrs: { x1: '0', y1: '0', x2: '1', y2: '1' },
//     stops: [
//       { offset: 0, color: 'rgb(89.56% 22.41% 27.51%)' },
//       { offset: 1, color: 'rgb(16.47% 61.57% 56.08%)' }
//     ]
//   }
// ]

Error Handling

Error Cause
Duplicate defs ID 'x' ID conflicts with another gradient, mask, clipPath, or pattern
LinearGradient() expects 5 arguments Wrong argument count
RadialGradient() expects 4-6 arguments Wrong argument count
ConicGradient() expects 3 arguments Wrong argument count
Pattern() expects 5 arguments Wrong argument count
First argument must be a string Non-string ID
stop() offset must be a number Non-numeric stop offset
stop() color must be a Color value Non-Color stop color
requires an angle unit Bare number on conic from/to (use 135deg)
direction must be 'cw' or 'ccw' Invalid conic direction
spread must be 'clamp', 'repeat', or 'transparent' Invalid conic spread
innerRadius must be a number Non-numeric innerRadius
innerRadius must be >= 0 Negative innerRadius
innerFill must be 'transparent', 'transparent-blend', 'center', or a Color value Invalid innerFill

Full Example

// Define a gradient palette
let warm = LinearGradient('warm', 0, 0, 0, 1) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(0.5, Color('#f4a261'));
  g.stop(1, Color('#e9c46a'));
};

let cool = RadialGradient('cool', 0.5, 0.5, 0.5) {|g|
  g.stop(0, Color('#2a9d8f'));
  g.stop(1, Color('#264653'));
};

// Use in layer styles
define PathLayer('bg') ${ fill: warm; stroke: none; }
define PathLayer('circle') ${ fill: cool; stroke: none; }

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

layer('circle').apply {
  circle(100, 100, 60)
}

Conic Gradient Rendering

Conic gradients are rasterized to bitmap and injected as SVG <pattern> elements because SVG has no native conic gradient primitive.

Playground (browser): When WebGPU is available (Chrome 113+), all conic gradients render through a WGSL fragment shader. This enables innerRadius/innerFill and consistent quality. Rendered textures are cached — unchanged gradients skip re-rendering. When WebGPU is unavailable (Firefox, Safari), the playground falls back to Canvas 2D's createConicGradient(), which does not support innerRadius or innerFill.

CLI: Conic gradients render as wedge-shaped SVG paths (pure math, no GPU). The innerRadius and innerFill properties are ignored with a warning.

Mesh Gradient

Create a mesh gradient with an ID, dimensions, and grid size:

let mesh = MeshGradient('terrain', 200, 200, 4, 3) {|g|
  g.getPoint(0, 0).color = Color('#264653');
  g.getPoint(0, 3).color = Color('#2a9d8f');
  g.getPoint(2, 0).color = Color('#e9c46a');
  g.getPoint(2, 3).color = Color('#e63946');
};

Constructor signature: MeshGradient(id, width, height, cols, rows) — creates a rows × cols grid of control points evenly spaced across the given dimensions.

  • cols and rows must be >= 2 (at least one patch)
  • All points start transparent (oklch(0 0 0 / 0))
  • The trailing block {|g| ... } is optional

Grid Access Methods

Method Arguments Returns Description
getPoint(row, col) row, col (numbers) MeshPoint Single control point at grid position
getRow(row) row (number) Array of MeshPoints All points in a row
getCol(col) col (number) Array of MeshPoints All points in a column
colorAll(color) Color value Set every point to the same color

MeshPoint Properties

Each point returned by getPoint, getRow, or getCol has:

Property Read Write Type
x yes yes number
y yes yes number
color yes yes Color

MeshPoint Methods

Method Arguments Description
translate(dx, dy) numbers Shift the point position

Mesh Gradient Properties

Expression Returns
mesh.id The gradient's string ID
mesh.cols Number of columns
mesh.rows Number of rows
mesh.width Width in user-space units
mesh.height Height in user-space units

Mesh Gradient Example

let m = MeshGradient('heat', 200, 200, 3, 3) {|g|
  // Color the corners
  g.getPoint(0, 0).color = Color('#264653');
  g.getPoint(0, 2).color = Color('#2a9d8f');
  g.getPoint(2, 0).color = Color('#e9c46a');
  g.getPoint(2, 2).color = Color('#e63946');

  // Shift a point for artistic control
  g.getPoint(1, 1).translate(10, -5);
  g.getPoint(1, 1).color = Color('#f4a261');
};

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

Rendering

Mesh gradients are rasterized via WebGPU using bilinear patch interpolation. Each quad cell in the grid is rendered as a smooth color blend between its four corner points.

  • Playground: WebGPU shader renders each patch; the result is injected as <pattern><image/></pattern>, same as conic gradients.
  • CLI: Mesh gradients are not supported in the CLI wedge-path renderer. A warning is emitted and the gradient renders as transparent.

Freeform Gradient

Create a freeform (scattered-point) gradient with an ID and dimensions:

let ff = FreeformGradient('glow', 200, 200) {|g|
  g.point(100, 100, Color('#ffffff'));
  g.point(0, 0, Color('#264653'));
  g.point(200, 0, Color('#2a9d8f'));
  g.point(200, 200, Color('#e63946'));
  g.point(0, 200, Color('#e9c46a'));
};

Constructor signature: FreeformGradient(id, width, height) — creates an empty gradient canvas. Add points with .point(x, y, color).

Methods

Method Arguments Description
point(x, y, color) x, y (numbers), color (Color) Add a color point at the given position

Freeform Gradient Properties

Expression Returns
ff.id The gradient's string ID
ff.width Width in user-space units
ff.height Height in user-space units
ff.falloff Distance falloff exponent (default 2.0)

Falloff

The falloff property controls how quickly colors blend with distance. Higher values create sharper boundaries around each point; lower values create smoother blends:

ff.falloff = 1.0;   // very smooth, linear falloff
ff.falloff = 2.0;   // default — inverse-square (natural)
ff.falloff = 4.0;   // tight halos around each point

falloff must be a positive number.

Freeform Gradient Example

let nebula = FreeformGradient('nebula', 300, 300) {|g|
  g.point(150, 150, Color('#ffffff'));
  g.point(50, 80, Color('#e63946'));
  g.point(250, 80, Color('#2a9d8f'));
  g.point(80, 250, Color('#f4a261'));
  g.point(220, 250, Color('#264653'));
};
nebula.falloff = 3.0;

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

Rendering

Freeform gradients are rasterized via WebGPU using inverse-distance weighted interpolation. Each pixel's color is a weighted average of all control points, where the weight is 1 / distance^falloff.

  • Playground: WebGPU shader computes IDW per-pixel; the result is injected as <pattern><image/></pattern>.
  • CLI: Freeform gradients are not supported in the CLI. A warning is emitted and the gradient renders as transparent.

A warning is also emitted at compile time if a freeform gradient has fewer than 2 points.

Error Handling

Error Cause
MeshGradient() expects 5 arguments Wrong argument count
MeshGradient() first argument must be a string Non-string ID
MeshGradient() width, height, cols, rows must be numbers Non-numeric dimensions
MeshGradient() cols and rows must be >= 2 Grid too small
FreeformGradient() expects 3 arguments Wrong argument count
FreeformGradient() first argument must be a string Non-string ID
FreeformGradient() width and height must be numbers Non-numeric dimensions
getPoint(row, col) out of bounds Index outside grid
getRow(row) out of bounds Row index outside grid
getCol(col) out of bounds Column index outside grid
point() expects 3 arguments (x, y, color) Wrong argument count
FreeformGradient falloff must be positive Non-positive falloff

TopoGradient

Topological gradients define smooth surfaces using closed-path contours at specific elevations, like topographic map contour lines rendered as a smooth gradient. Each contour carries its own color, creating a natural mapping from shape to color.

Constructor

TopoGradient(id, width, height)
Argument Type Description
id string Unique gradient identifier
width number Gradient coordinate width
height number Gradient coordinate height

Contours

Each contour defines a closed path at a specific elevation with a color. Contours are the color stops of a topological gradient — the gradient interpolates between them based on distance.

g.contour(projectedPath, elevation, color)
Argument Type Description
projectedPath ProjectedPathValue Closed path from .project(x, y)
elevation number Elevation level (0–1)
color Color Color at this elevation

The path must be closed (end with closePath()). Use @{ ... } path blocks with .project(x, y) to position contours in absolute space.

Properties

Property Read Write Type Default Description
id yes no string Gradient ID
width yes no number Render width
height yes no number Render height
easing yes yes string 'linear' Easing: linear, smoothstep, ease-in, ease-out, ease-in-out
interpolation yes yes string 'srgb' Color interpolation space
method yes yes string 'distance' Solver: 'distance' (SDF-based) or 'laplace' (Jacobi iteration)
iterations yes yes number 200 Jacobi iterations for Laplace solver (range: 1–2000). Only meaningful when method = 'laplace'; ignored by 'distance'. Higher values produce smoother results but take longer to compute.
baseColor yes yes Color Color outside all contours (elevation 0)

Basic Example

// Define contour shapes
let shore = @{
  M(0, 0)
  C(100, -40, 250, 30, 300, 100)
  C(270, 210, 30, 220, 0, 0)
  closePath()
};

let peak = @{ circle(0, 0, 40); closePath() };

let topo = TopoGradient('terrain', 400, 300) {|g|
  g.contour(shore.project(50, 50), 0.3, Color('#f9e79f'))
  g.contour(shore.scale(0.7, 0.7).project(100, 90), 0.55, Color('#27ae60'))
  g.contour(peak.project(200, 150), 0.8, Color('#6e2c00'))
};
topo.baseColor = Color('#1a5276');
topo.easing = 'smoothstep';

define PathLayer('bg') ${ fill: topo; }
layer('bg').apply { rect(0, 0, 400, 300) }

Programmatic Contours

Contours can be generated procedurally using loops:

let topo = TopoGradient('rings', 400, 400) {|g|
  for ([level, i] in [0.2, 0.4, 0.6, 0.8]) {
    let r = calc(150 - i * 35);
    let ring = @{ circle(0, 0, r); closePath() };
    g.contour(ring.project(200, 200), level, Color('#27ae60'))
  }
};
topo.baseColor = Color('#1a5276');

Multiple Peaks / Islands

Non-nested contours at the same elevation create separate features. Each pixel's elevation is determined by its innermost containing contour.

let topo = TopoGradient('archipelago', 600, 400) {|g|
  // Main island
  g.contour(mainIsland.project(100, 100), 0.35, Color('#f9e79f'))
  g.contour(mainPeak.project(180, 160), 0.7, Color('#6e2c00'))

  // Small island (separate, not nested)
  g.contour(smallIsland.project(450, 280), 0.35, Color('#f9e79f'))
  g.contour(smallPeak.project(460, 290), 0.6, Color('#27ae60'))
};
topo.baseColor = Color('#1a5276');

Algorithm

TopoGradient supports two solver methods for computing the elevation field.

Distance Solver (method = 'distance')

The default method uses distance-based SDF (Signed Distance Field) interpolation:

  1. Containment test: For each contour, ray-cast to determine if the pixel is inside (even-odd rule)
  2. Floor elevation: Highest elevation among all contours containing the pixel
  3. Ceiling elevation: Lowest elevation among contours NOT containing the pixel but above the floor
  4. Distance interpolation: Compute minimum distances to floor and ceiling boundaries, interpolate elevation
  5. Easing: Apply the easing function to the interpolation parameter
  6. Color lookup: Sample the color ramp (built from contour colors sorted by elevation)

Laplace Solver (method = 'laplace')

The Laplace solver computes the mathematically smoothest possible surface between contours by solving the Laplace equation ∇²h = 0 with contour pixels as boundary conditions. This produces results like a rubber sheet stretched between fixed-elevation boundaries.

The solver uses Jacobi iteration: each non-boundary pixel is repeatedly replaced with the average of its 4 neighbors until the field converges. The iterations property controls how many passes are performed (default: 200).

Distance vs Laplace comparison:

  • Distance (SDF): Fast, uses signed distance blending with smooth transition zones. Produces concentric-like gradients that follow contour shapes. Best for: decorative gradients, radial-style effects.
  • Laplace: Solves for the harmonic function, producing physically correct potential field flow. Elevation changes smoothly around corners and between non-nested contours. Best for: terrain/height maps, natural-looking blends, multi-peak topologies.
let s = @{ circle(0, 0, 80); closePath() };
let topo = TopoGradient('terrain', 400, 300) {|g|
  g.contour(s.project(200, 150), 0.3, Color('#2ecc71'))
  g.contour(s.project(200, 150, 0.5, 0.5), 0.7, Color('#e74c3c'))
};
topo.method = 'laplace';
topo.iterations = 300;
topo.baseColor = Color('#1a5276');

Rendering

TopoGradient is rasterized per-pixel:

  • Playground: WebGPU shader with SDF computation (fast); Canvas 2D fallback on Firefox/Safari (slower)
  • CLI: Warning emitted, solid-color approximation rendered

Error Handling

Error Cause
TopoGradient() expects 3 arguments Wrong argument count
TopoGradient() first argument must be a string Non-string ID
TopoGradient() width and height must be numbers Non-numeric dimensions
.contour() expects 3 arguments Wrong argument count
.contour() first argument must be a ProjectedPathValue Non-projected path
.contour() elevation must be between 0 and 1 Out-of-range elevation
.contour() third argument must be a Color value Non-Color color
.contour() path must be closed Path not ending with closePath()
TopoGradient easing must be one of: ... Invalid easing value
TopoGradient iterations must be a number When setting iterations to a non-number
TopoGradient iterations must be between 1 and 2000 When iterations is out of range

CSSVar Type

CSSVar creates CSS custom property references (var()) that can be used in style blocks. This lets SVGs generated by Pathogen be parameterized by the consuming page's CSS.

Constructor

let v = CSSVar('--primary');                    // no fallback
let v = CSSVar('--primary', '#e63946');         // string fallback
let v = CSSVar('--primary', Color('#e63946'));  // Color fallback

The variable name must start with --. The optional fallback can be a plain string or a Color value (which auto-converts to its CSS representation).

Properties

Property Type Description
.var string The variable name (e.g. --primary)
.fallback string or null The fallback value, or null if none
.css string The full var() expression
let v = CSSVar('--primary', '#e63946');
log(v.var);       // --primary
log(v.fallback);  // #e63946
log(v.css);       // var(--primary, #e63946)

Style Blocks

CSSVar values auto-convert in style blocks — no .css needed:

let fg = CSSVar('--foreground', '#333');

define PathLayer('main') ${ stroke: fg; fill: CSSVar('--fill', 'none'); }

This produces stroke="var(--foreground, #333)" and fill="var(--fill, none)" in the SVG output.

Composition with Color

CSSVar composes with the Color type for typed fallbacks:

let brand = Color('#e63946');
let fg = CSSVar('--primary', brand);
// fg.css → var(--primary, #e63946)

Display

log() displays CSSVar values in constructor form:

let v = CSSVar('--primary', '#e63946');
log(v);  // CSSVar(--primary, #e63946)

let v2 = CSSVar('--bg');
log(v2);  // CSSVar(--bg)

Template literals also use this form:

let v = CSSVar('--primary', '#e63946');
log(`color: ${v}`);  // color: CSSVar(--primary, #e63946)

Markers

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

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

Creating a Marker

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

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

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

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

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

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

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

Appending Paths

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

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

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

Using Markers in Styles

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

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

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

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

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

Default Attribute Values

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

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

Mutable Properties

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

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

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

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

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

Orient

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

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

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

// Fixed angle — an Angle value works directly
fixedMarker.orient = 45deg;

// Plain numbers are radians
plainMarker.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. Angle values are accepted too — marker.orient = 45deg; works, including via a variable.

context-stroke and context-fill

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

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

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

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

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

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

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

Multiple Markers on One Path

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

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

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

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

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

Generated SVG Output

The basic arrow example above produces:

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

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

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

Properties

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

Methods

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

Auto-Wrapping

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

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

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

Error Handling

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

Masks and Clip Paths

Masks and clip paths are SVG <defs> elements that control visibility of layers. They're created with Mask() and ClipPath() constructors and referenced from layer style blocks.

Masks

A mask uses luminance to control visibility — white areas are fully visible, black areas are hidden, and gray values create partial transparency.

Creating a Mask

let m = Mask('my-mask');

The argument is the mask's ID string. IDs must be unique across all masks and clip paths.

Appending Paths

Use .append(path, styles?) to add path elements to the mask:

let base = @{ m 0 0 l 200 0 l 0 200 l -200 0 z };
let hole = @{ m 50 50 l 100 0 l 0 100 l -100 0 z };

let m = Mask('reveal');
m.append(base, ${ fill: white; });    // visible area
m.append(hole, ${ fill: black; });    // hidden cutout

The first argument accepts either a PathBlock or a ProjectedPath. PathBlocks are automatically projected at the origin (0, 0). The optional second argument is a style block for the path element.

Using a Mask

Reference the mask from a layer's style block using the .id property:

define PathLayer('art') ${ mask: m.id; }
layer('art').apply {
  M 10 10 L 190 190
}

The mask property automatically wraps the ID with url(#...), so m.id (which returns 'reveal') becomes mask: url(#reveal) in the output.

Full Example

// Define mask geometry
let fullRect = @{ m 0 0 l 200 0 l 0 200 l -200 0 z };
let circle = @{ m 100 50 a 50 50 0 1 1 0 100 a 50 50 0 1 1 0 -100 };

// Create mask: white = visible, black = hidden
let m = Mask('circle-reveal');
m.append(fullRect, ${ fill: black; });
m.append(circle, ${ fill: white; });

// Apply mask to layer
define PathLayer('drawing') ${ mask: m.id; stroke: #333; stroke-width: 2; }
layer('drawing').apply {
  for (i in 0..20) {
    M 0 calc(i * 10)
    L 200 calc(i * 10)
  }
}

This draws horizontal lines that are only visible inside the circular mask.

Clip Paths

A clip path uses geometry to clip content — anything inside the path is visible, everything outside is hidden. Unlike masks, clip paths don't use styles (they're purely geometric).

Creating a Clip Path

let c = ClipPath('my-clip');

Appending Paths

Use .append(path) to add path elements. No styles parameter — clip paths are geometry-only:

let shape = @{ m 20 20 l 160 0 l 0 160 l -160 0 z };
let c = ClipPath('frame');
c.append(shape);

Using a Clip Path

define PathLayer('scene') ${ clip-path: c.id; }
layer('scene').apply {
  M 0 0 L 200 200
}

Like masks, the clip-path property automatically wraps the ID with url(#...).

Auto-Wrapping

The following CSS properties automatically wrap bare ID strings with url(#...):

  • mask
  • clip-path
  • filter
  • marker-start
  • marker-mid
  • marker-end

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

// These produce the same output:
define PathLayer('a') ${ mask: m.id; }         // m.id → 'my-mask' → url(#my-mask)
define PathLayer('b') ${ mask: url(#my-mask); } // already wrapped, left as-is

Properties

Property Returns Description
.id string The raw ID string passed to the constructor

Methods

Method Applies To Description
.append(path, styles?) Mask Add a path element with optional styles
.append(path) ClipPath Add a path element (no styles)

Error Handling

  • Duplicate IDs across masks and clip paths throw an error
  • Constructor requires exactly one string argument
  • .append() requires a PathBlock or ProjectedPath as the first argument
  • Mask .append() requires a style block as the optional second argument

Filters

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

Pathogen ships seven custom filter constructors:

Custom filters live in the shared <defs> block alongside gradients, patterns, masks, and markers, and are referenced via url(#id). They compile to stock SVG filter primitives, so they render identically in the CLI, the playground, and the VS Code preview — with one documented exception for progressive motion blur in non-Chromium engines (see MotionBlurFilter).

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.

MotionBlurFilter

MotionBlurFilter() adds directional and progressive blur — effects CSS cannot express today (they are still only a proposal). Pathogen synthesizes them from SVG filter primitives. Linear blur renders in every SVG engine; Progressive blur relies on feImage and needs a Chromium-class engine (see Renderer support below). Two types are available:

  • Linear — a directional smear along an angle, the way a fast-moving object blurs along its path of travel.
  • Progressive — blur that ramps across the element (sharp at one edge, increasingly blurred toward the other), the "variable blur" popularized by iOS frosted surfaces.
let smear = MotionBlurFilter() {|f|
  f.type = MotionBlurType.Linear;
  f.distance = 20;
  f.angle = 30deg;
  f.samples = 12;
};

define PathLayer('car') ${
  fill: oklch(60% 0.2 250);
  filter: smear;
}
layer('car').apply { roundRect(40, 80, 120, 40, 8); }

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

distance and angle use the same coordinate conventions as the rest of the language: distance is in user-space units (the same units as your viewBox, not CSS pixels), and angle takes an Angle value0deg points right (+x), 90deg points down (+y), matching EmbossFilter and ElevationShadowFilter. Angle values carried in variables work the same as inline literals.

Properties

Property Type Default Effect
type MotionBlurType Linear Linear (directional smear) or Progressive (spatial ramp)
distance number ≥ 0 10 Linear: smear length. Progressive: maximum blur radius at the far edge
angle number (angle unit) 0deg Linear: smear direction. Progressive: ramp direction (sharp → blurred)
samples integer 2–32 12 Tap count / quality. Linear only — Progressive is continuous and ignores it. Higher = smoother smear at more cost

Generated SVG Output — Linear

For MotionBlurFilter() {|f| f.type = MotionBlurType.Linear; f.distance = 20; f.angle = 30deg; f.samples = 4; } (samples = 4 reduced from the default of 12 for a readable chain):

<filter id="pathogen-motion-blur-1" x="-50%" y="-50%" width="200%" height="200%">
  <feOffset in="SourceGraphic" dx="-8.660254" dy="-5" result="t0o"/>
  <feComponentTransfer in="t0o" result="acc0"><feFuncA type="linear" slope="0.25"/></feComponentTransfer>
  <feOffset in="SourceGraphic" dx="-2.886751" dy="-1.666667" result="t1o"/>
  <feComponentTransfer in="t1o" result="t1s"><feFuncA type="linear" slope="0.25"/></feComponentTransfer>
  <feComposite in="acc0" in2="t1s" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="acc1"/>
  <feOffset in="SourceGraphic" dx="2.886751" dy="1.666667" result="t2o"/>
  <feComponentTransfer in="t2o" result="t2s"><feFuncA type="linear" slope="0.25"/></feComponentTransfer>
  <feComposite in="acc1" in2="t2s" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="acc2"/>
  <feOffset in="SourceGraphic" dx="8.660254" dy="5" result="t3o"/>
  <feComponentTransfer in="t3o" result="t3s"><feFuncA type="linear" slope="0.25"/></feComponentTransfer>
  <feComposite in="acc2" in2="t3s" operator="arithmetic" k1="0" k2="1" k3="1" k4="0" result="acc3"/>
  <feGaussianBlur in="acc3" stdDeviation="5.196152 3"/>
</filter>

Physical motion blur is the average of the source translated along the motion vector over the shutter interval. Each of the samples taps is one feOffset copy, centered on the source (t ∈ [-0.5, +0.5] so the smear is symmetric rather than a one-sided ghost). Each tap is scaled to 1/samples alpha by feFuncA slope, and the taps are summed with feComposite operator="arithmetic" (k2=k3=1). Summation — not feMerge — is what makes it a true average: feMerge over-composites, biasing the result toward the last tap and brightening it.

A finite number of taps would otherwise read as discrete ghost copies on a hard-edged shape, so a final feGaussianBlur fuses them into a continuous smear. Its stdDeviation is a two-value, direction-aligned blur (stdDeviation="σ·cos σ·sin", here 30°"5.196 3") sized to the tap spacing — it blurs along the motion to merge the copies while leaving the perpendicular edge (the object's cross-section) crisp. A higher samples count tightens the tap spacing and shrinks this smoothing automatically.

Generated SVG Output — Progressive

For MotionBlurFilter() {|f| f.type = MotionBlurType.Progressive; f.distance = 8; f.angle = 90deg; }:

<linearGradient id="pathogen-motion-blur-1-ramp" x1="0.5" y1="0" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
  <stop offset="0" stop-color="#000" stop-opacity="0"/>
  <stop offset="1" stop-color="#000" stop-opacity="1"/>
</linearGradient>
<rect id="pathogen-motion-blur-1-mask" x="0" y="0" width="100%" height="100%" fill="url(#pathogen-motion-blur-1-ramp)"/>
<filter id="pathogen-motion-blur-1" x="-10%" y="-10%" width="120%" height="120%">
  <feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blurred"/>
  <feImage href="#pathogen-motion-blur-1-mask" result="ramp"/>
  <feComponentTransfer in="ramp" result="invRamp"><feFuncA type="table" tableValues="1 0"/></feComponentTransfer>
  <feComposite in="SourceGraphic" in2="invRamp" operator="in" result="sharpPart"/>
  <feComposite in="blurred" in2="ramp" operator="in" result="blurPart"/>
  <feComposite in="sharpPart" in2="blurPart" operator="arithmetic" k1="0" k2="1" k3="1" k4="0"/>
</filter>

Progressive blur crossfades the sharp source and a blurred copy along a gradient. The sibling <linearGradient> runs in objectBoundingBox units, and the mask <rect> fills 100% of it, so the ramp automatically tracks the element's own bounding box — a small element gets the full sharp→blurred ramp across its own extent, not the viewport's. The feImage pulls that ramp into the filter; the sharp source is masked by the inverted ramp and the blurred copy by the ramp, so the near edge shows only the sharp pixels and the far edge only the blur. The two halves are summed with feComposite operator="arithmetic" rather than merged: the masks are exact complements (invRamp + ramp = 1), so the sum holds full opacity across the crossfade — a plain feMerge would over-composite and dip a solid shape to ~75% alpha through the midband, lightening it. The region is kept tight (-10% / 120%) because an oversized region would compress the ramp to a middle band and wash the effect out. Because the gradient vector is set from angle, any direction works (a non-square element skews a diagonal ramp slightly, an inherent objectBoundingBox gradient behavior).

Renderer support

The limitation is about the rendering engine, not which surface or embedding you use. Progressive blur relies on feImage referencing a local gradient. In Chromium-class engines (Chrome, Edge, and the Electron-based VS Code preview) it renders correctly in every context we tested — inline in a page, embedded as <img>, and rasterized to a canvas (so playground previews and PNG thumbnails are fine). Support is weaker in non-Chromium engines, notably Firefox, where progressive blur may not render as intended; because the playground and CLI output delegate feImage to the host browser, that applies wherever the output is opened in such an engine. If you need Progressive blur to be engine-independent, prerender the blurred region as a raster, or fall back to Linear, which uses only feOffset / feComposite and renders everywhere. This is the same class of cross-engine filter concern covered in Browser Caveats.

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;
};

MotionBlurType

MotionBlurType selects which blur a MotionBlurFilter produces.

Member String value Effect
MotionBlurType.Linear linear Directional smear along angle — averaged offset copies
MotionBlurType.Progressive progressive Blur ramps across the element along angle, sharp edge → blurred edge
let pan = MotionBlurFilter() {|f|
  f.type = MotionBlurType.Linear;
  f.distance = 28;
  f.angle = 0deg;
};

let frosted = MotionBlurFilter() {|f|
  f.type = MotionBlurType.Progressive;
  f.distance = 10;
  f.angle = 90deg;
};

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:

Native CSS syntax — spaces, not commas. CSS filter functions are space-separated: drop-shadow(4px 4px 8px oklch(0.65 0.26 357)), and chains are space-separated too: blur(2px) brightness(1.2). Writing Pathogen-style commas (drop-shadow(4px, 4px, 4px, color)) is a compile error with a fix-it message — previously this silently emitted invalid CSS that browsers dropped. See CSS Function Values. The editor suggests correctly-shaped filter functions and any in-scope filter variables after filter:.

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.

Native filter arguments can be dynamic. A bare variable substitutes where the function takes a unitless number (brightness, contrast, grayscale, invert, opacity, saturate, sepia); lengths and angles need a unit, so splice those with a template fragment. Getting it wrong is a compile error rather than a silently dropped declaration — blur(2) reports that it needs a unit. See Variables and Interpolation in Values and Argument Units:

let level = randomRange(1.1, 1.4);
let softness = randomRange(1, 3);

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

let shimmer = GroupLayer('shimmer') ${
  filter: brightness(level) blur(`${softness}`px);
};
shimmer.append(art);

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
MotionBlurFilter() takes no positional arguments — configure via the trailing block Calling MotionBlurFilter(...) with any arguments
Invalid value '<x>' for MotionBlurFilter.type. Valid values: linear, progressive Assigning an unrecognized string to f.type
MotionBlurFilter.type must be a MotionBlurType enum value Assigning a non-string to f.type
MotionBlurFilter.distance must be a finite non-negative number Negative, Infinity, or NaN
MotionBlurFilter.angle must be a finite number (with angle unit) Non-number, Infinity, or NaN on f.angle
MotionBlurFilter.samples must be an integer between 2 and 32 Non-integer, out of range, or wrong type
Cannot assign to MotionBlurFilter property '<x>' Assigning to an unrecognized property name
Property '<x>' does not exist on MotionBlurFilter Reading an unrecognized property
Cannot assign to GlowFilter property '<x>' Assigning to an unrecognized property name on a GlowFilter
Cannot assign to EmbossFilter property '<x>' Same, EmbossFilter
Cannot assign to ElevationShadowFilter property '<x>' Same, ElevationShadowFilter
Cannot assign to InnerShadowFilter property '<x>' Same, InnerShadowFilter
Cannot assign to PixelateFilter property '<x>' Same, PixelateFilter
Property '<x>' does not exist on GlowFilter Reading an unrecognized property on a GlowFilter
Property '<x>' does not exist on EmbossFilter Same, EmbossFilter
Property '<x>' does not exist on ElevationShadowFilter Same, ElevationShadowFilter
Property '<x>' does not exist on InnerShadowFilter Same, InnerShadowFilter
Property '<x>' does not exist on PixelateFilter Same, PixelateFilter

See Also

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

Grid

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

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

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

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

Creating a Grid

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

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

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

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

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

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

Constructor options

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

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

Members

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

Cell access

get(row, col)

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

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

set(row, col, value)

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

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

getPoint(row, col)

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

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

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

getRow(row) and getCol(col)

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

cells()

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

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

Iteration

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

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

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

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

A reusable field function (a worker) applies with << instead of a block — field.fill() << waveFn; — see Applying workers. The same applies to forEach and map below.

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

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

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

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

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

Sampling at arbitrary positions

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

sampleNearest(x, y)

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

sampleBilinear(x, y)

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

sample(x, y)

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

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

Example: a flow-field arrow grid

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

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

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

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

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

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

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

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

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

Bilinear sampling — what it is and when to use it

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

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

The math

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

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

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

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

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

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

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

The angle-wraparound catch

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

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

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

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

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

See also

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

Objects

Objects are key-value containers for grouping related data — coordinates, configuration, metadata, or any structured values.

Object Literals

Create objects with curly braces and key: value pairs:

let obj = {};
let point = { x: 50, y: 80 };
let config = { name: 'Dave', age: 32, cats: ['foo', 'bar', 'baz'] };

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 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,
};

Shorthand Properties

When a property value is a variable with the same name as the key, you can write the name once — { x } is shorthand for { x: x }:

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

Shorthand shines when accumulating records in a loop, or at call sites that take an options object:

let offsets = [];
for ([glyph, gIndex] in ['a', 'b', 'c']) {
  let leftOffset = calc(60 + gIndex * 48);
  offsets.push({ glyph, leftOffset });
}
let xDim = 10;
let yDim = 10;
let grid = Grid(4, 5, { xDim, yDim });

Shorthand, regular properties, and spread mix freely, and later properties still override earlier ones:

let radius = 40;
let base = { cx: 100, cy: 100, radius: 10 };
let spec = { ...base, radius };   // { cx: 100, cy: 100, radius: 40 }

This is the mirror of destructuring, which unpacks with the same one-name syntax:

let { x, y } = point;    // destructure out...
let copy = { x, y };     // ...and pack back up

The symmetry stops at failures, though: destructuring a key that doesn't exist gives null, while packing a name that isn't defined is an error.

Two constraints on the shorthand form:

  • The key must be a plain identifier. String keys ({ 'first-name' }) and computed keys ([expr]: value) are parse errors.
  • The name is an ordinary variable reference. If it isn't defined, evaluation fails with Undefined variable: name.

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]

Assigning to an element of an array that is currently being iterated — from inside a .map/.filter/.reduce/.sort block or a for (item in arr) body — is an error. See the syntax reference's Reference Semantics section for the iteration lock.

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

Unlike arrays, objects are not locked during iteration — for (key in obj) walks a snapshot of the keys, and mutating the object inside the body is allowed. The iteration lock described in the syntax reference's Reference Semantics section applies to arrays only.

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.

Destructuring Built-in Values

Destructuring is not limited to object literals — it also works on Pathogen's fixed-shape struct values, the built-ins with a known, fixed set of properties: Point, PolarVector, Grid, MeshPoint (from mesh gradients), Color, Angle (let { deg, rad, pi, turns } = 90deg; — see Angle Units), and context objects like ctx.position. It does not work on open-ended built-ins like TextBlock or ProjectedPath, even though those support dot access.

let point = Point(20, 20);
let { x, y } = point;
log(x);  // 20
log(y);  // 20
let { angle, distance } = PolarVector(45deg, 100);
// angle is 0.7853... — a plain number in radians, not an Angle value:
// an Angle goes into the constructor, a number comes back out

let grid = Grid(4, 5, { xDim: 10, yDim: 10 });
let { rows, cols, width, height } = grid;
// rows is 4, cols is 5, width is 50, height is 40

let { lightness, chroma, hue } = oklch(0.7 0.15 200);
// lightness is 0.7, chroma is 0.15, hue is 200

Because path commands move the pen, you can destructure the live pen position mid-path from ctx.position:

M 50 50
L 120 80
let { x, y } = ctx.position;
// x is 120, y is 80

Renaming works too:

let { x: px, y: py } = Point(10, 30);
// px is 10, py is 30

Unlike object literals, built-in values have a fixed set of properties, so destructuring a key that doesn't exist is an error rather than null — the same behavior as dot access:

let { z } = Point(1, 2);
// Error: Property 'z' does not exist on Point

The rest pattern collects the remaining properties into a plain object. Computed properties are included — for example, a Grid rest includes width and height:

let { x, ...rest } = Point(1, 2);
// x is 1, rest is { y: 2 }

let { rows, cols, ...dims } = Grid(4, 5, { xDim: 10, yDim: 10 });
// dims.width is 50, dims.height is 40
// dims also includes xDim, yDim, origin, outOfBounds, interpolation

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.

Angle values log in their written unit (90deg, 0.5pi) rather than as a radians number — use .rad or .deg to log a bare number.

ctx Object

The ctx object tracks path state during evaluation:

ctx.position

Current pen position after the last command.

Property Description
ctx.position.x X coordinate
ctx.position.y Y coordinate
M 100 50
log(ctx.position)  // {x: 100, y: 50}
L 150 75
log(ctx.position)  // {x: 150, y: 75}

ctx.start

Subpath start position (set by M/m, used by Z).

Property Description
ctx.start.x X coordinate
ctx.start.y Y coordinate

ctx.commands

Array of all executed commands with their positions:

// Each entry contains:
{
  command: "L",        // Command letter
  args: [150, 75],     // Evaluated arguments
  start: {x: 100, y: 50},
  end: {x: 150, y: 75}
}

Using ctx in Paths

Access position values with calc():

M 50 50
// Draw relative to current position
L calc(ctx.position.x + 30) ctx.position.y
circle(ctx.position.x, ctx.position.y, 5)

Example: Debug a Loop

M 20 100
for (i in 0..4) {
  log("iteration", i, ctx.position)
  L calc(ctx.position.x + 40) 100
}

This logs the iteration number and current position at each step, helping you trace how the path is constructed.

CLI Reference

The pathogen-lang CLI compiles extended SVG path syntax into standard SVG path strings or complete SVG files.

Installation

npm install -g pathogen-lang

Or use with npx:

npx pathogen-lang [options]

Basic Usage

Compile a File

pathogen-lang input.svgx

Or with the explicit flag:

pathogen-lang --src=input.svgx

Compile Inline Code

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

Read from Stdin

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

Output Options

Output Path Data to File

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

Output as Complete SVG File

Generate a complete SVG file with the path embedded:

pathogen-lang --src=input.svgx --output-svg-file=output.svg

This creates a ready-to-use SVG file that can be opened in any browser or image viewer.

Log Output

Pathogen programs can use log() to produce diagnostic output. By default, the CLI discards log entries. Two flags expose them:

Print to stderr

pathogen-lang -e 'let x = 42; log(x); M x 0' --print-logs

Output on stderr:

[line 1] x = 42

The path data still goes to stdout, so logs don't interfere with piping:

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

Write structured JSON

pathogen-lang --src=input.pathogen --log-file=logs.json

This writes the full LogEntry[] array with line numbers and typed parts:

[
  {
    "line": 3,
    "parts": [
      { "type": "value", "label": "x", "value": "42" }
    ]
  }
]

Both flags can be combined:

pathogen-lang --src=debug.pathogen --print-logs --log-file=logs.json --output-svg-file=out.svg

Annotated Output

Use --annotated to get a human-readable debug output that shows:

  • Original comments preserved in place
  • Loop iterations with line numbers
  • Function call annotations with expanded output
  • Each path command on its own line

This is useful for debugging complex path generation or understanding how your code produces its output.

Basic Usage

pathogen-lang -e 'for (i in 0..3) { M i 0 }' --annotated

Output:

//--- for (i in 0..3) from line 1
  //--- iteration 0
  M 0 0
  //--- iteration 1
  M 1 0
  //--- iteration 2
  M 2 0
  //--- iteration 3
  M 3 0

With Comments

pathogen-lang -e '// Draw points
for (i in 0..3) { M i 0 }' --annotated

Output:

// Draw points

//--- for (i in 0..3) from line 2
  //--- iteration 0
  M 0 0
  //--- iteration 1
  M 1 0
  //--- iteration 2
  M 2 0
  //--- iteration 3
  M 3 0

Loop Truncation

Long loops (>10 iterations) are automatically truncated to show the first 3 and last 3 iterations:

pathogen-lang -e 'for (i in 0..100) { M i 0 }' --annotated

Output:

//--- for (i in 0..100) from line 1
  //--- iteration 0
  M 0 0
  //--- iteration 1
  M 1 0
  //--- iteration 2
  M 2 0
  ... 95 more iterations ...
  //--- iteration 98
  M 98 0
  //--- iteration 99
  M 99 0
  //--- iteration 100
  M 100 0

Function Call Annotations

Function calls show their name, arguments, and expanded output:

pathogen-lang -e 'circle(50, 50, 25)' --annotated

Output:

//--- circle(50, 50, 25) called from line 1
  M 25 50
  A 25 25 0 1 1 75 50
  A 25 25 0 1 1 25 50

Save to File

pathogen-lang --src=complex.svgx --annotated -o debug-output.txt

SVG Styling Options

When using --output-svg-file, you can customize the appearance:

Option Default Description
--stroke=<color> #000 Stroke color
--fill=<color> none Fill color
--stroke-width=<n> 2 Stroke width
--viewBox=<box> 0 0 200 200 SVG viewBox
--width=<w> 200 SVG width
--height=<h> 200 SVG height

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

Examples

Red circle with no fill:

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

Blue filled polygon:

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

Large canvas with custom viewBox:

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

Help and Version

pathogen-lang --help
pathogen-lang -h

pathogen-lang --version
pathogen-lang -v

Exit Codes

Code Meaning
0 Success
1 Error (parse error, file not found, etc.)

File Extensions

By convention, source files use the .svgx extension, but any text file will work.

Examples

Generate a Spiral

pathogen-lang -e '
M 100 100
for (i in 1..50) {
  L calc(100 + cos(i * 0.3) * i * 1.5) calc(100 + sin(i * 0.3) * i * 1.5)
}
' --output-svg-file=spiral.svg --stroke=teal --stroke-width=2

Process Multiple Files

for file in examples/*.svgx; do
  pathogen-lang --src="$file" --output-svg-file="${file%.svgx}.svg"
done

Use in a Build Script

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

Examples

Practical examples showing how to use pathogen-lang for common tasks.

Basic Shapes

Simple Rectangle

rect(10, 10, 180, 80)

Circle

circle(100, 100, 50)

Rounded Rectangle

roundRect(20, 40, 160, 120, 15)

Using Variables

Centered Circle

let width = 200;
let height = 200;
let cx = calc(width / 2);
let cy = calc(height / 2);
let r = 40;

circle(cx, cy, r)

Configurable Star

let centerX = 100;
let centerY = 100;
let outerR = 60;
let innerR = 25;
let points = 5;

star(centerX, centerY, outerR, innerR, points)

Loops and Patterns

Row of Circles

for (i in 0..5) {
  circle(calc(30 + i * 35), 100, 15)
}

Grid of Dots

for (row in 0..5) {
  for (col in 0..5) {
    circle(calc(20 + col * 40), calc(20 + row * 40), 5)
  }
}

Concentric Circles

let cx = 100;
let cy = 100;

for (i in 1..6) {
  circle(cx, cy, calc(i * 15))
}

Trigonometry

Points on a Circle

let cx = 100;
let cy = 100;
let r = 60;
let points = 8;

for (i in 0..points) {
  let angle = calc(i / points * TAU());
  let x = calc(cx + cos(angle) * r);
  let y = calc(cy + sin(angle) * r);
  circle(x, y, 5)
}

Spiral

M 100 100
for (i in 1..100) {
  let angle = calc(i * 0.2);
  let r = calc(i * 0.8);
  L calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)
}

Sine Wave

M 0 100
for (i in 1..40) {
  let x = calc(i * 5);
  let y = calc(100 + sin(i * 0.3) * 30);
  L x y
}

Flower Pattern

let cx = 100;
let cy = 100;
let petalCount = 6;
let petalRadius = 25;
let centerRadius = 15;

// Petals
for (i in 0..petalCount) {
  let angle = calc(i / petalCount * TAU());
  let px = calc(cx + cos(angle) * 35);
  let py = calc(cy + sin(angle) * 35);
  circle(px, py, petalRadius)
}

// Center
circle(cx, cy, centerRadius)

Custom Functions

Reusable Square

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

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

Diamond Shape

fn diamond(cx, cy, size) {
  M cx calc(cy - size)
  L calc(cx + size) cy
  L cx calc(cy + size)
  L calc(cx - size) cy
  Z
}

diamond(100, 100, 40)

Arrow

fn arrow(x1, y1, x2, y2, headSize) {
  // Line
  M x1 y1
  L x2 y2

  // Arrowhead (simplified)
  let angle = atan2(calc(y2 - y1), calc(x2 - x1));
  let a1 = calc(angle + 2.5);
  let a2 = calc(angle - 2.5);

  M x2 y2
  L calc(x2 - cos(a1) * headSize) calc(y2 - sin(a1) * headSize)
  M x2 y2
  L calc(x2 - cos(a2) * headSize) calc(y2 - sin(a2) * headSize)
}

arrow(20, 100, 180, 100, 15)

Conditionals

Size-Based Shape

let size = 80;

if (size > 50) {
  circle(100, 100, size)
} else {
  rect(calc(100 - size / 2), calc(100 - size / 2), size, size)
}

Alternating Pattern

for (i in 0..10) {
  let x = calc(20 + i * 18);
  if (calc(i % 2) == 0) {
    circle(x, 100, 8)
  } else {
    rect(calc(x - 6), 94, 12, 12)
  }
}

Complex Examples

Gear Shape

let cx = 100;
let cy = 100;
let innerR = 30;
let outerR = 50;
let teeth = 12;

M calc(cx + outerR) cy

for (i in 0..teeth) {
  let a1 = calc(i / teeth * TAU());
  let a2 = calc((i + 0.3) / teeth * TAU());
  let a3 = calc((i + 0.5) / teeth * TAU());
  let a4 = calc((i + 0.8) / teeth * TAU());

  L calc(cx + cos(a1) * outerR) calc(cy + sin(a1) * outerR)
  L calc(cx + cos(a2) * outerR) calc(cy + sin(a2) * outerR)
  L calc(cx + cos(a3) * innerR) calc(cy + sin(a3) * innerR)
  L calc(cx + cos(a4) * innerR) calc(cy + sin(a4) * innerR)
}

Z

// Center hole
circle(cx, cy, 10)

Recursive-Style Tree (using loops)

// Simple branching pattern
fn branch(x, y, length, angle, depth) {
  let x2 = calc(x + cos(angle) * length);
  let y2 = calc(y + sin(angle) * length);
  M x y
  L x2 y2
}

let startX = 100;
let startY = 180;

// Trunk
M startX startY
L startX 120

// Main branches
for (i in 0..5) {
  let angle = calc(-1.57 + (i - 2) * 0.4);
  let len = calc(30 - abs(i - 2) * 5);
  branch(startX, 120, len, angle, 0)
}

Tips

  1. Start simple: Build complex shapes from simple parts
  2. Use variables: Makes code readable and adjustable
  3. Extract functions: Reuse common patterns
  4. Test incrementally: Generate SVGs often to see results
  5. Use comments: Document your intent for complex sections

Security & SVG Sanitization

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

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

Compiler contract

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

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

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

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

What's allowed in style { … } blocks

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

  • Numbers, with optional unit: px, em, rem, %, deg, rad, turn, s, ms, pt, pc, in, cm, mm, vw, vh, vmin, vmax, fr, ch, ex. Example: stroke-width: 2, font-size: 1.25rem, transform: rotate(45deg). The unit is optional for a bare value like this, but inside a CSS function it is checked per functionblur() requires a length, cubic-bezier() requires bare numbers — see Argument Units. That check is a correctness guard, not a security boundary: it prevents emitting declarations browsers silently drop.
  • 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(...), hwb(...), color(...), color-mix(...), light-dark(...). Example: fill: oklch(0.7 0.15 240).
  • CSS transform, filter, basic-shape, and timing functions from the same fixed allow-list: the translate/rotate/scale/skew/matrix/perspective families, the 10 filter functions (blur, brightness, contrast, drop-shadow, grayscale, hue-rotate, invert, opacity, saturate, sepia), inset/circle/ellipse/polygon/path, and cubic-bezier/steps. Filter functions are space-separated — a top-level comma inside them (or between chained filters) is a compile error, since it would emit invalid CSS the browser silently drops. See CSS Function Values.
  • Local fragment refs (#ident) on properties that take URLs: mask, clip-path, filter, fill, stroke. Example: mask: "#myMask".
  • Quoted strings on string-typed properties: font-family, content. Example: font-family: "Inter, sans-serif".
  • Pathogen CSSVar() values (auto-converted to var(...) during emission). Use this instead of writing var() literally — see below.

Backtick template interpolation (whole-value or fragments inside a value) is evaluated before this allow-list runs — the interpolated result is validated exactly like a hand-written value, so interpolation cannot smuggle in a rejected form. var() is the one form the compiler may emit itself (from a CSSVar() reference); those emitted strings are allow-listed individually, so a var() that merely appears elsewhere in the same value — hand-typed or produced by interpolation — is still rejected.

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 an explicit allow-list: defs, g, symbol, use, path, circle, ellipse, line, polygon, polyline, rect, image, text, tspan, linearGradient, radialGradient, pattern, mask, clipPath, marker, stop, filter, and the SVG filter primitives (feBlend, feColorMatrix, feGaussianBlur, feMerge, feMergeNode, … the full fe* family). Anything not on this list — including HTML/MathML containers such as <div>, <meta>, <img>, <math> — is rejected. (This is an allow-list: unknown elements fail closed, so a novel breakout element cannot slip through.)
  • All on* event-handler attributes (including any namespace-prefixed *:on… alias).
  • Inline style="..." attributes (and *:style aliases) and <style> blocks. Use a Pathogen style { … } block on the surrounding layer instead — that goes through the value allow-list above.
  • href / xlink:href (and any namespace-prefixed *:href) attributes whose value is not a local fragment (#name). A data:image/(png|jpeg|gif|webp|svg+xml);base64,... URI is additionally allowed only on <image> / <feImage> (rendered in image mode); on <use> and every other element the value must be a local fragment.
  • Presentation attributes that take a url(...) value — fill, stroke, mask, clip-path, filter, marker, marker-start, marker-mid, marker-end — whose url(...) is not a local fragment (url(#name)). A remote or data: url() is rejected (outbound-fetch / tracking vector).
  • Unquoted attribute values (not valid XML, and a tokenizer/parser mismatch surface).
  • XML comments, CDATA sections, DOCTYPE declarations, and processing instructions.

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

Playground & blog rendering

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

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

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

Reporting a vulnerability

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

Publishing Workspaces

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

Who can publish

You can submit a workspace for review once you:

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

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

Submitting for review

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

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

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

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

Editing a published workspace

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

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

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

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

Limits

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

Workspace URLs

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

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

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

Reviewer access

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

Exporting Your Work

Pathogen Studio exports any workspace from a single Export dialog — open it from the overflow menu (Export) or press Ctrl/Cmd+Shift+E. Three formats are available:

  • SVG — a self-contained vector file for the web, further editing, or re-import into design tools.
  • PNG — a raster image at the resolution you choose, with optional transparency.
  • PDF — a print-ready document with real page sizes, margins, bleed and crop marks, and all text converted to vector outlines.

Whatever you see in the dialog's live preview is exactly what downloads: the preview shows your artwork, the optional legend, and the branding line as they will appear in the file.

The legend (optional)

A legend is a caption card embedded in the export — title, creator, date, description, and the Pathogen source that produced the artwork. It's off by default; flip Include legend on to add it.

  1. Fill in the legend fields — name, description, export date, and creator. The Source Code block is included automatically.
  2. Drag the legend card anywhere over the artwork; drag its corner handle to resize. Enable Snap in the floating control next to the zoom pill for grid-aligned placement.
  3. Choose a format and press Download.

Syntax highlighting (on by default) colors the legend's source listing with Pathogen's print palette — the same token colors used across pathogen.studio, tuned for the legend's white card. The colors are baked into the file as literal fills, so they survive PDF text outlining and PNG rasterization identically. Turn the checkbox off for a plain monochrome listing.

Under Advanced Export Settings you can include the workspace grid in the export and set its color.

Branding

Every export carries a small Created in pathogen.studio attribution:

  • With the legend on, it appears as the legend card's footer.
  • With the legend off, a quiet single line marks the artwork's bottom-right corner.

The line is shown in the live preview, so its placement is never a surprise.

SVG export

The SVG export is fully self-contained: fonts used by the legend and branding line are embedded directly in the file as data URIs, so the file renders identically offline, in any browser, with no font substitution. Use SVG when you want to keep the export editable, embed it in a web page, or re-import it into design tools.

PNG export

The PNG export rasterizes exactly what the preview shows — artwork, legend, branding, and (if enabled) the grid — with fonts rendered from the same embedded data as the SVG export.

  • Scale — 1×, 2× (default), or 4× of the artwork's ViewBox units, or Custom width in pixels; height always follows the artwork's aspect ratio. A live summary line shows the computed pixel dimensions. Sizes are capped at 16,384 px per side.
  • Transparent background — omits the workspace background color, for compositing the artwork over other designs.

PDF export (print-ready)

The PDF export is built for handing off to a print shop. It solves the problem that generic SVG-to-PDF converters routinely mangle: font fidelity. Before the PDF is generated, every piece of text — in your artwork, the legend, and the branding line — is converted to vector outlines. The PDF contains no font references at all, so there is nothing for a print shop's software to substitute. What you see in the editor is what comes off the press, at any size.

Page sizing

Three ways to size the page, selected from Page size:

  • Match artwork — exact print size. You enter the printed size of the artwork itself; the aspect ratio is locked to your ViewBox, so editing width recomputes height and vice versa. The page is the artwork plus your margins — the artwork prints at exactly the size you typed, with no letterboxing. This is the mode to use when you know how large the piece should be on the wall.
  • Presets. US sizes (18 × 24 in and 24 × 36 in posters, Letter, Tabloid) and ISO A-series (A4 through A0), with a portrait/landscape toggle. The artwork scales to fit the printable area (page minus margins), centered.
  • Custom page. Enter any page width and height (1–100 inches per side). A chain-link toggle locks the page to your artwork's aspect ratio; unlock it for a free page shape, and the artwork scales to fit, centered.

A single Units selector (inches or centimeters) applies to every dimension — sizes and margins — and each input shows its unit beside the value. A live summary line beneath the size controls spells out exactly what will print: the artwork's printed dimensions and the resulting page size.

Margins, bleed, and crop marks

  • Margins inset the printable area on all four sides (default 0.5 in). In Match-artwork mode they extend the page outward instead, so the artwork size you entered is preserved.
  • Bleed + crop marks adds a 0.125 in / 3 mm bleed around the trimmed page and corner crop marks outside it — the setup commercial printers ask for on edge-to-edge posters. The bleed convention follows your Units selection: 0.125 in when working in inches, 3 mm when working in centimeters. The artwork background color extends to the bleed edge, so there are no white slivers after trimming. A fully transparent workspace background prints as paper white, and a semi-transparent one is flattened over white — the printed tint matches what you see over a white canvas.

If you're unsure whether your print shop wants bleed, leave the option on for posters and off for framed prints with a border.

Cover sheet

Cover sheet — preview + print specs adds a job-ticket page in front of your artwork. Page 1 is Letter-sized (A4 when your Units are centimeters) and carries a fast raster preview of the finished piece, a specification manifest — trim size in your units, page size including bleed, margins, bleed and crop marks, artwork mode and detail, precision, export date, and creator — and handling notes for the print counter: print or send page 2 only, and (for vector artwork) a heads-up that the artwork page may take a while to render in PDF viewers even though it prints correctly.

The cover also solves a practical annoyance with dense vector artwork: Finder, Quick Look, and Preview all render page 1 for thumbnails and the initial view. With a cover in front, the file previews instantly instead of appearing broken while a heavy artwork page rasterizes. That's why the option defaults on when complex artwork is detected (the same heuristic that defaults Raster mode), and off otherwise.

One caveat: some automated print-upload portals require single-page files and validate page counts. If you're uploading to one of those rather than handing the file to a person, turn the cover sheet off.

Like everything else in the PDF export, the cover's text is converted to vector outlines — the whole document stays font-free.

Vector or raster artwork

The Artwork setting chooses how your artwork is written into the PDF:

  • Vector — exact path geometry, crisp at any print size. The default for most artwork.
  • Raster — the artwork is embedded as a print-resolution image (300 DPI, sized for your page). Text outlines, the legend, and the branding line stay vector either way.

Very complex artwork — hundreds of thousands of path segments, as dense generative patterns can produce — makes technically valid vector PDFs that Preview, Acrobat, and print-shop software render for minutes or show as blank. The export detects this and defaults such artwork to Raster, which previews instantly and prints reliably; you can always switch back to Vector.

Optimizing output

Two controls trim the exported file without changing how it looks.

Precision (under Advanced Export Settings — applies to every format). Pathogen emits coordinates at full floating-point precision by default. The workspace's footer Precision setting (also available as --to-fixed <N> in the CLI) rounds every coordinate at compile time, affecting the live preview and everything downstream. The export dialog's Precision select goes one step further: it trims decimals for this export only, without touching your workspace setting or preview. It defaults to Match workspace, only ever removes precision (never adds it back), and rewrites artwork paths as absolute coordinates so rounding can never accumulate into visible drift. Text outlines and the legend are left untouched. Two decimals is comfortably sub-pixel for screen SVGs and far below print resolution.

Detail (PDF format, Vector artwork only). Dense generative artwork often packs many path segments inside the area of a single printed dot — invisible individually, but every one of them costs the print shop's software rendering time. The Detail select removes segments smaller than a threshold derived from your chosen print size:

  • Full — every segment, exact geometry (the default for typical artwork).
  • Fine — culls segments smaller than half a printed dot at 300 DPI.
  • Standard — culls segments smaller than one printed dot (1/300 in) at the chosen print size. The default when complex artwork is detected.

Visual error is bounded by the threshold — by construction, below what a 300 DPI printer can reproduce. The export dialog reports what was removed (e.g. "Detail: removed 18,240 of 96,411 path segments"). Detail is unavailable in Raster mode, where resolution is already governed by the 300 DPI image.

PDF coordinates are additionally written at bounded float precision, which keeps the file free of 17-digit floating-point artifacts.

These passes apply to artwork path data only — transforms, gradient geometry, and basic shapes are left as-is.

What stays vector

Paths, strokes, linear and radial gradients, patterns, clip paths, and all text (as outlines) are written to the PDF as true vectors — they stay crisp at any print size. Two constructs always fall back to a high-resolution raster:

  • Conic, mesh, and freeform gradients are rasterized (as they are in every Pathogen output).
  • If your artwork uses masks or filters, the artwork layer is embedded as a 300 DPI image sized for your chosen page; the legend remains vector. A notice appears in the export dialog when this happens.

Known limitations

  • Text outlining fetches font glyph data from Google Fonts, using the Latin subset. Text in non-Latin scripts may be missing glyphs in the outlined output.
  • Outlined text is no longer selectable or searchable in the PDF — a deliberate trade for guaranteed print fidelity.
  • Extremely complex artwork (for example, thousands of layers with filters) can exceed what your browser is able to rasterize at 300 DPI. The export verifies every raster and automatically retries — first in tiles at full resolution, then at stepped-down sizes — with live progress shown in the export dialog, which also reports the resolution actually achieved. If even the minimum fails, the export stops with an error rather than producing a blank image.