# pathogen-lang Documentation

> This is the complete documentation for pathogen-lang in a single page.
> For the formatted version with navigation, see [the HTML docs](/docs).

---

# 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`](#viewbox) declaring the canvas, and typically one or more [layer definitions](#layers) 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-blocks-path-blocks).

## Path Commands

All standard SVG path commands are supported:

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

Uppercase commands use absolute coordinates; lowercase use relative coordinates.

```
M 0 0 L 100 100 Z
```

## Variables

Declare variables with `let`:

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

Use variables directly in path commands:

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

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

## Strings and Template Literals

String values use double quotes:

```
let name = "World";
```

Template literals use backticks with `${expression}` interpolation:

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

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

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

### `.length`

Returns the number of characters in the string:

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

### `.empty()`

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

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

### Index Access

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

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

Out-of-bounds access throws an error.

### `.split()`

Splits a string into an array of individual characters:

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

### `.append(value)`

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

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

### `.prepend(value)`

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

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

### `.includes(substring)`

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

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

### `.slice(start, end)`

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

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

## Color Literals

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

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

See the [Color documentation](color.md) for full details.

## Percent Suffix

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

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

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

## Expressions with calc()

For mathematical expressions, wrap them in `calc()`:

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

### Supported Operators

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

Operator precedence follows standard mathematical conventions.

## Style Blocks

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

### Literals

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

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

### Merge (`<<`)

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

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

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

Multiple merges can be chained: `a << b << c`. See also [Objects — Merging](objects.md#merging-objects-).

### 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](layers.md) for full details.

## Null

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

```
let x = null;
```

### Truthiness

`null` is falsy in conditionals:

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

### Equality

`null` is only equal to itself:

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

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

### Error Behavior

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

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

## Booleans

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

```
let flag = true;
let check = false;
```

### Numeric Equivalence

Booleans participate in arithmetic as their numeric values:

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

### Display

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

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

### Truthiness

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

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

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

### Logical Operators

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

### Arc Flags

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

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

## Enums

### Built-in Enums

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

| Enum | Members |
|------|---------|
| `Easing` | `Linear`, `Smoothstep`, `EaseIn`, `EaseOut`, `EaseInOut` |
| `Interpolation` | `SRGB`, `OKLCH`, `LinearRGB` |
| `SpreadMethod` | `Pad`, `Reflect`, `Repeat` |
| `GradientUnits` | `ObjectBoundingBox`, `UserSpaceOnUse` |
| `Direction` | `CW`, `CCW` |
| `ConicSpread` | `Clamp`, `Repeat`, `Transparent` |
| `InnerFill` | `Transparent`, `TransparentBlend`, `Center` |
| `TopoMethod` | `Distance`, `Laplace` |

```
topo.easing = Easing.Smoothstep;       // equivalent to 'smoothstep'
grad.interpolation = Interpolation.OKLCH;
```

Enum values are interchangeable with their string equivalents:

```
Easing.Linear == 'linear'  // true
```

### User-Defined Enums

Define custom enums with `enum`:

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

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

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

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

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

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

## Points

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

### Constructor

Create a point with `Point(x, y)`:

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

### Properties

| Property | Returns | Description |
|---|---|---|
| `.x` | number | X coordinate |
| `.y` | number | Y coordinate |

```
let pt = Point(100, 200);
M pt.x pt.y           // M 100 200
L calc(pt.x + 10) pt.y  // L 110 200
```

### Methods

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

#### `.translate(dx, dy)`

Returns a new point offset by the given deltas:

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

#### `.polarTranslate(angle, distance)`

Returns a new point offset by angle and distance:

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

#### `.midpoint(other)`

Returns the midpoint between two points:

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

#### `.lerp(other, t)`

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

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

#### `.rotate(angle, origin)`

Rotates this point around a center point:

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

#### `.distanceTo(other)`

Returns the Euclidean distance between two points:

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

#### `.angleTo(other)`

Returns the angle in radians from this point to another:

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

#### `.offset(other)`

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

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

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

### Display

`log()` shows points in a readable format:

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

### Template Literals

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

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

## Arrays

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

### Literals

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

### Spread (`...`)

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

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

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

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

### Index Access

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

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

Out-of-bounds access throws an error.

### `.length`

Returns the number of elements:

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

### `.empty()`

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

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

### Methods

#### `.push(value)`

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

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

#### `.pop()`

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

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

#### `.unshift(value)`

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

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

#### `.shift()`

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

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

#### `.slice(start, end?)`

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

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

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

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

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

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

The block receives up to three parameters:
- `item` — the current element
- `index` (optional) — the zero-based index
- `arrayRef` (optional) — a reference to the original array

```
let prices = [10, 25, 50];
let doubled = prices.map {|price|
  return calc(price * 2);
};
// doubled is [20, 50, 100]

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

Use the index parameter for position-aware transforms:

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

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

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

The block has access to variables from the enclosing scope:

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

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

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

The block receives up to four parameters:
- `accumulator` — the current accumulated value
- `item` (optional) — the current element
- `index` (optional) — the zero-based index
- `arrayRef` (optional) — a reference to the original array

```
let sum = [1, 2, 3, 4].reduce(0) {|acc, n|
  return calc(acc + n);
};
// sum is 10

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

On an empty array, `reduce` returns `initialValue` unchanged:

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

#### `.mapSlice(length)`

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

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

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

### Reference Semantics

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

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

### For-Each Iteration

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

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

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

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

Iterating over an empty array produces no output.

### Destructuring

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

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

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

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

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

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

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

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

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

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

## Angle Units

Numbers can have angle unit suffixes for convenience:

| Suffix | Description |
|--------|-------------|
| `45deg` | Degrees (converted to radians internally) |
| `1.5rad` | Radians (no conversion) |
| `0.25pi` | Multiplied by π (i.e. `0.25 * π`) |

```
let angle = 90deg;
M sin(45deg) cos(45deg)

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

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

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

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

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

## For Loops

Repeat path commands with `for`:

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

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

### Descending Ranges

Ranges automatically count down when start > end:

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

### Nested Loops

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

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

## Conditionals

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

```
let size = 100;

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

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

## Functions

### Defining Functions

Create reusable path generators with `fn`:

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

### Calling Functions

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

Functions can call other functions and use all language features.

## Comments

Line comments start with `//`:

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

## Path Context (ctx)

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

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

### ctx Properties

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

### How Position Updates

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

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

### log() Function

Use `log()` to inspect the context during evaluation:

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

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

### Example: Drawing Relative to Current Position

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

## Complete Example

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

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

---

# Standard Library Reference

pathogen-lang includes built-in functions for math operations and common SVG shapes.

## Math Functions

### Trigonometry

All trigonometric functions use radians.

| Function | Description |
|----------|-------------|
| `sin(x)` | Sine |
| `cos(x)` | Cosine |
| `tan(x)` | Tangent |
| `asin(x)` | Arc sine |
| `acos(x)` | Arc cosine |
| `atan(x)` | Arc tangent |
| `atan2(y, x)` | Two-argument arc tangent |

```
// Draw a point on a circle
let angle = 0.5;
let r = 50;
M calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)
```

### Angle Conversion

| Function | Description |
|----------|-------------|
| `rad(degrees)` | Convert degrees to radians |
| `deg(radians)` | Convert radians to degrees |

```
// Use degrees instead of radians
let angle = rad(45);
M calc(cos(angle) * 50) calc(sin(angle) * 50)
```

### Exponential & Logarithmic

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

### Rounding

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

### Utility

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

### Interpolation & Clamping

| Function | Description |
|----------|-------------|
| `lerp(a, b, t)` | Linear interpolation: `a + (b - a) * t` |
| `clamp(value, min, max)` | Constrain value to range |
| `map(value, inMin, inMax, outMin, outMax)` | Map value from one range to another |

```
// Interpolate between two positions
let t = 0.5;
M calc(lerp(0, 100, t)) calc(lerp(0, 50, t))

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

### Constants

| Function | Returns |
|----------|---------|
| `PI()` | 3.14159... |
| `E()` | 2.71828... |
| `TAU()` | 6.28318... (2π) |
| `mpi(x)` | `x * π` (multiply by π) |

```
// Draw a semicircle
let r = 50;
for (i in 0..20) {
  let angle = calc(i / 20 * PI());
  L calc(100 + cos(angle) * r) calc(100 + sin(angle) * r)
}
```

### Random

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

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

### Cycler

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

#### Cycler(array, shuffle?)

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

```
let c = Cycler(['red', 'green', 'blue']);
c.pick()  // 'red'
c.pick()  // 'green'
c.pick()  // 'blue'
c.pick()  // 'red' (wraps around)
```

```
// Shuffled cycler — stable order across wraps
let r = Cycler(['a', 'b', 'c'], true);
```

#### .pick()

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

#### .length

Returns the number of items in the cycler.

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

### PolarVector

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

#### PolarVector(angle, distance)

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

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

#### .angle

Returns the angle in radians.

#### .distance

Returns the distance.

#### .turn(deltaAngle)

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

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

#### .scale(factor)

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

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

#### .mirror()

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

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

---

## Path Functions

These functions generate complete path segments.

### circle(cx, cy, r)

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

```
circle(100, 100, 50)
```

Output: A full circle using two arc commands.

### rect(x, y, width, height)

Draws a rectangle.

```
rect(10, 10, 80, 60)
```

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

Draws a rectangle with rounded corners.

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

### polygon(cx, cy, radius, sides)

Draws a regular polygon.

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

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

Draws a star shape.

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

### line(x1, y1, x2, y2)

Draws a line segment.

```
line(0, 0, 100, 100)
```

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

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

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

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

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

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

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

Draws a cubic bezier curve.

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

### polarCubicBezier(start, pv1, pv2, end)

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

- **pv1** — direction and distance from `start` to the first control point
- **pv2** — direction and distance from `end` to the second control point

```
let a = Point(0, 100);
let b = Point(100, 100);
polarCubicBezier(a, PolarVector(rad(-60), 40), PolarVector(rad(-120), 40), b)
```

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

PolarVector methods compose naturally for handle manipulation:

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

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

### moveTo(x, y)

Returns a move command. Useful inside functions.

```
moveTo(50, 50)
```

### lineTo(x, y)

Returns a line command.

```
lineTo(100, 100)
```

### closePath()

Returns a close path command.

```
closePath()
```

### cubicSpline(points)

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

**Point schema:**

| Property | Type | Description |
|----------|------|-------------|
| `x` | number | X coordinate |
| `y` | number | Y coordinate |
| `angle` | number | Tangent angle (radians; use `rad()` or `deg` suffix for degrees) |
| `exit` | number | Distance from point along tangent to outgoing control point (omit on last point) |
| `entry` | number | Distance backward along tangent to incoming control point (omit on first point) |

```
cubicSpline([
  { x: 0, y: 100, angle: 0, exit: 30 },
  { x: 50, y: 0, angle: 0, entry: 20, exit: 25 },
  { x: 100, y: 100, angle: 0, entry: 30 }
])
```

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

### quadSpline(start, points, end)

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

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

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

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

### clippedQuadSpline(start, points, end)

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

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

- `exitTime = 1`, `entryTime = 1`: mathematically equivalent to quadratic
- `exitTime = 0.5`, `entryTime = 0.5`: control points at half arm length (moderate dampening)
- `exitTime = 0`, `entryTime = 0`: linear segments

```
clippedQuadSpline(
  { x: 0, y: 0, angle: 0, exit: 100, exitTime: 0.5 },
  [],
  { x: 200, y: 0, entryTime: 0.5 }
)
```

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

### Grid Functions

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

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


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

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

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

- `type` — `GridPatternType` 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](path-blocks.md), `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](color.md) 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](cssvar.md) for constructor forms, properties, and examples.

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

---

## Using Functions Inside calc()

Math functions can be used inside `calc()`:

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

Path functions are called at the statement level:

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

---

# ViewBox

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

## Syntax

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

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

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

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

## Arguments

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

```
let W = 400;
let H = 300;
define ViewBox(0, 0, W, H);
```

```
define ViewBox(0, 0, calc(100 * 4), calc(100 * 3));
```

## Negative Origin

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

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

## Default ViewBox

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

## Placement

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

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

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

## Errors

The compiler rejects:

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

## Precedence with the CLI

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

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

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

## Why source, not configuration

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

## Related

- [Layers](#layers) — the rest of the `define` family (`PathLayer`, `TextLayer`, `GroupLayer`)
- [CLI](#cli) — using `--viewBox`/`--width`/`--height` flags alongside source-defined viewBox

---

# Layers

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

See also [`define ViewBox`](#viewbox) for declaring the SVG viewBox — a sibling `define`-family statement that controls the canvas dimensions.

## Defining Layers

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

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

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

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

### Default Layer

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

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

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

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

## Writing to Layers

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

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

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

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

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

### Context Isolation

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

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

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

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

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

## Accessing Layer Context

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

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

M 50 50
L 150 80
L 100 150

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

Available context properties:

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

## Dynamic Layer Names

Layer names can be expressions, including variables:

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

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

## Dynamic Layer Creation

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

### Constructor Expression

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

The style block is optional:

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

### Style Mutation with `<<`

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

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

### Explicit `.styles` Property

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

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

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

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

### TextLayer Constructor

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

### Accessing Layer Properties

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

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

### Coexistence with `define`

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

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

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

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

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

## Style Properties

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

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

Each property is a semicolon-terminated declaration:

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

## Output Format

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

```js
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:

```js
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

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

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

### Restrictions

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

## Style Blocks

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

### Style Block Literals

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

### Merge Operator (`<<`)

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

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

### Property Access

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

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

### Expression Evaluation in Values

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

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

### Layer Definitions with Style Expressions

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

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

### Per-Element Styles on Text and Tspan

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

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

## Transforms

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

### Translate

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

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

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

### Rotate

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

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

### Scale

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

### Reset

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

### Read Access

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

### Default Context (No Layers)

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

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

### Inside Apply Blocks

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

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

### Combined Transforms

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

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

### Transform Convenience Properties

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

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

Shorthands for translate and scale accept comma-separated values:

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

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

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

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

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

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

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

### Per-Layer Isolation

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

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

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

## GroupLayer

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

### Definition

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

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

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

### Adding Children with `.append()`

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

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

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

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

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

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

### Nesting Groups

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

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

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

### Transforms

GroupLayers support both style block transforms and imperative transforms:

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

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

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

### Moving Layers Between Groups

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

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

### No Apply Blocks

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

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

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

## Limitations

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

---

# Path Blocks

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

## Syntax

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

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

## Drawing a Path Block

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

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

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

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

### Assigning the draw result

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

### Drawing at a specific position

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

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

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

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

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

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

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

## Projecting Without Drawing

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

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

## Properties

### PathBlock

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

### ProjectedPath

Same properties as PathBlock but with absolute coordinates.

### subPathCommands entries

Each entry in `subPathCommands` is an object with:

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

## Control Flow Inside Path Blocks

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

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

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

## Accessing Outer Variables

Path blocks can read variables from enclosing scope:

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

## First-Class Values

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

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

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

## Using Path Metadata

Access path properties for layout calculations:

```
let segment = @{ v 20 h 30 };

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

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

## Restrictions

Path blocks enforce these rules at runtime:

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

## Parametric Sampling

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

These methods work on both PathBlock values and ProjectedPath values.

### `get(t)` → Point

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

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

### `tangent(t)` → `{ point, angle }`

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

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

### `normal(t)` → `{ point, angle }`

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

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

### `partition(n)` → OrientedPoint[]

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

| Property | Type | Description |
|---|---|---|
| `point` | `Point` | Position at this sample |
| `angle` | `number` | Tangent angle (radians) |
| `t` | `number` | Arc-length fraction (`i / n`) |

```
let p = @{ h 100 };
let pts = p.partition(4);    // 5 points at x = 0, 25, 50, 75, 100
for (op in pts) {
  log(op.point.x, op.angle, op.t);
}
// t values: 0, 0.25, 0.5, 0.75, 1
```

### Sampling on ProjectedPath

Projected paths return absolute coordinates:

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

### Curve Support

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

## Transforms

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

### `reverse()` → PathBlock / ProjectedPath

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

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

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

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

### `boundingBox()` → `{ x, y, width, height }`

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

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

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

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

### `intersects(geometry)` → Boolean

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

**Accepted arguments:**

| Argument type | Comparison |
|---|---|
| PathBlock or ProjectedPath | Bounding box vs bounding box |
| ProjectedText | Path bbox vs text bbox |
| `{x, y, width, height}` object | Path bbox vs rectangle |

```
let a = @{ h 60 v 40 h -60 z };
let b = @{ h 40 v 30 h -40 z };

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

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

Testing against a rectangle object:

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

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

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

### `intersectionPoints(geometry)` → Array\<Point\>

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

**Accepted arguments:**

| Argument type | Returns |
|---|---|
| PathBlock or ProjectedPath | Points where bbox edges cross path segments |
| ProjectedText | Corners of the overlap rectangle (4 points), or empty array if no overlap |

```
let box = @{ h 100 v 100 h -100 z };
let line = @{ m -10 50 h 120 };

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

Non-overlapping paths return an empty array:

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

### `offset(distance)` → PathBlock / ProjectedPath

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

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

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

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

### `mirror(angle)` → PathBlock / ProjectedPath

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

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

Common angles:
- `mirror(0)` — horizontal axis (y → -y)
- `mirror(0.5pi)` — vertical axis (x → -x)
- `mirror(0.25pi)` — diagonal (swaps x and y)

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

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

### `rotateAtVertexIndex(index, angle)` → PathBlock / ProjectedPath

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

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

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

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

### `scale(sx, sy)` → PathBlock / ProjectedPath

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

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

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

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

### `subPath(startT, endT)` → PathBlock

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

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

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

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

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

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

Edge cases:

- `subPath(0, 1)` returns approximately the original path
- `subPath(t, t)` returns an empty PathBlock (not an error)
- Works with all command types including curves and arcs

```
let curve = @{ c 0 -40 50 -40 50 0 };
let front = curve.subPath(0, 0.5);
M 0 50
curve.draw()
M 0 80
front.draw()                        // first half of the Bézier curve
```

### Transforms on ProjectedPath

Projected paths return results in absolute coordinates:

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

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

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

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

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

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

## Concatenation (`<<`)

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

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

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

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

Self-concatenation repeats the path:

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

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

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

The `<<` operator also works for [style block merging](syntax.md#style-blocks). The operand types must match — mixing PathBlocks and style blocks throws an error.

## Chamfers

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

### `chamfer(distance)` → PathBlock / ProjectedPath

Chamfers all corner vertices with equal distance on both sides:

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

### `chamfer(d1, d2)` → PathBlock / ProjectedPath

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

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

### `chamferAtVertex(index, distance)` → PathBlock / ProjectedPath

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

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

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

Asymmetric chamfer at a single vertex:

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

### Edge cases

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

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

## Fillets

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

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

### `fillet(radius)` → PathBlock / ProjectedPath

Fillets all corner vertices with the given radius:

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

### `filletAtVertex(index, radius)` → PathBlock / ProjectedPath

Fillets a single vertex:

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

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

## Elliptical Fillets

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

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

### `ellipticalFillet(rx, ry)` → PathBlock / ProjectedPath

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

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

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

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

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

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

Elliptical fillet at a single vertex:

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

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

Elliptical fillet at a single vertex with rotation:

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

## Boolean Operations

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

See also: [Standard Library path functions](stdlib.md#path-functions) for creating shapes to use with boolean operations.

### `union(other)` → PathBlock

Combines two paths into their union (outer boundary):

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

### `difference(other)` → PathBlock

Subtracts `other` from the path:

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

### `intersection(other)` → PathBlock

Returns only the overlapping region:

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

### `xor(other)` → PathBlock

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

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

### Requirements and behavior

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

## Font Integration

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

### @font Directive

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

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

**Syntax:**

```
@font "family-or-path" [weight];
```

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

**Font loading by environment:**

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

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

### PathBlock.fromGlyph(text, styles)

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

```
@font "Inter";

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

M 50 100
glyphs[0].draw()
```

**Arguments:**

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

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

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

### advanceWidth

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

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

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

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

### contours

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

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

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

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

### Error cases

| Condition | Error message |
|-----------|---------------|
| Wrong number of arguments | `PathBlock.fromGlyph() expects 2 arguments (text, styles)` |
| First argument not a string | `PathBlock.fromGlyph() first argument must be a string` |
| Second argument not a style block | `PathBlock.fromGlyph() second argument must be a style block` |
| Style block missing font-family | `PathBlock.fromGlyph() requires font-family in style block` |
| No fonts loaded | `PathBlock.fromGlyph() requires font data, but no fonts were loaded. If you wrote an @font directive, font loading may have failed earlier — look for a preceding font-loading error.` |
| Font not in registry | `Font 'X' not found in font registry. Available fonts: [list]` |

---

# TextBlock

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

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

## Quick Overview

```pathogen
// 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:

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

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

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

```pathogen
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](cli.md) or compile options
- `font-family` must be set in the TextBlock's styles
- Only available on TextBlockValue (not ProjectedTextValue)

```pathogen
@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](path-blocks.md), you can chain any PathBlock operation:

```pathogen
// 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).

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

```pathogen
// ✗ 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

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

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

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

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

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

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

---

# Color Type

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

## Color Literals

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

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

Color literals support method chaining via parentheses:

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

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

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

## CSS Color Function Literals

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

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

Method chaining works directly:

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

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

## Constructor

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

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

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

## Properties

Read-only properties for inspecting color values:

| Property | Type | Description |
|----------|------|-------------|
| `.css` | string | Hex if opaque, `rgba()` if transparent |
| `.hex` | string | `#rrggbb` (ignores alpha) |
| `.oklch` | string | `oklch(L C H)` or `oklch(L C H / a)` |
| `.hsl` | string | `hsl(H, S%, L%)` |
| `.rgb` | string | `rgb(R, G, B)` |
| `.lightness` | number | OKLCH lightness (0–1) |
| `.chroma` | number | OKLCH chroma (0–~0.4) |
| `.hue` | number | OKLCH hue (0–360) |
| `.a` | number | Alpha (0–1) |

```
let c = Color('#e63946');
log(c.hex);        // #e63946
log(c.lightness);  // ~0.52
log(c.hue);        // ~27
log(c.a);          // 1
```

## Methods

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

### Lightness

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

### Saturation

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

### Alpha

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

### Hue

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

### Mixing

Mix two colors in OKLCH space:

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

### Method Chaining

Methods return new Colors, so they chain naturally:

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

## Color Harmonies

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

### .analogous(angle?)

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

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

### .triadic()

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

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

### .tetradic()

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

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

### .splitComplementary(angle?)

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

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

### Using Harmonies

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

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

### Complete Example

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

let dsx = 260;
let dsp = 70;

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

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

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

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

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

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

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

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

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

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

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

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

Compile with: `pathogen-lang --output-svg-file=swatches.svg --viewBox="0 0 600 700" --width="600" --height="700"`

## Static Methods

### Color.mix(c1, c2, ratio)

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

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

### Color.palette(color, n)

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

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

### Color.palette(c1, c2, n)

Generate `n` evenly interpolated colors between two colors:

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

`n` must be an integer >= 2.

### Color.lightDark(light, dark)

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

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

Works with CSSVar-backed colors for full customizability:

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

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

## @property Declarations

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

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

```xml
<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

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

## Conic Gradient

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

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

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

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

### Conic Gradient Properties

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

### Angle Units Required

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

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

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

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

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

### Partial Sweep

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

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

### Direction

`direction` controls which way colors sweep within the arc:

- `'cw'` (default) — colors flow clockwise from `from` to `to`
- `'ccw'` — colors flow counter-clockwise (stop offsets are reversed)

```
let reversed = ConicGradient('rev', 100, 100) {|g|
  g.stop(0, Color('#000'));
  g.stop(1, Color('#fff'));
};
reversed.direction = 'ccw';
```

### Spread Modes

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

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

### Inner Radius

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

```
gauge.innerRadius = 30;
```

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

| Value | Effect |
|-------|--------|
| `'transparent'` | Hard cutoff — empty center (default) |
| `'transparent-blend'` | Smooth blend from transparent at center to gradient at edge |
| `'center'` | Smooth blend from first stop color at center to gradient at edge |
| `Color(...)` | Smooth blend from custom color at center to gradient at edge |

```
gauge.innerFill = 'transparent';        // hard donut hole (default)
gauge.innerFill = 'transparent-blend';  // soft transparent fade
gauge.innerFill = 'center';             // first-stop color, blends outward
gauge.innerFill = Color('#1a1a2e');     // custom color, blends outward
```

This is useful for donut-style gauges and ring charts. Inner radius rendering requires WebGPU, which is only available in the playground. The CLI wedge-path renderer ignores `innerRadius` and emits a warning when it is set.

```
// Ring gauge with transparent center and partial sweep
let ring = ConicGradient('ring', 100, 100) {|g|
  g.stop(0, Color('#2a9d8f'));
  g.stop(0.5, Color('#e9c46a'));
  g.stop(1, Color('#e63946'));
};
ring.from = 135deg;
ring.to = 405deg;
ring.innerRadius = 30;
ring.innerFill = 'transparent';  // donut hole
```

### Rendering

Since SVG has no native conic gradient element, the output depends on the consumer:

- **CLI** (`--output-svg-file`): Wedge-path SVG approximation wrapped in `<pattern>`. Each ~1° slice is an individual `<path>` element with an interpolated fill color.
- **Playground**: Canvas 2D `createConicGradient()` → rendered to a PNG image → injected as `<pattern><image/></pattern>` for higher quality.

Both approaches are referenced via `url(#id)` in `fill`/`stroke`, identical to native gradients.

### OKLCh Interpolation

Conic gradients support OKLCh interpolation via the shared `interpolation` and `steps` properties:

```
let smooth = ConicGradient('smooth', 100, 100) {|g|
  g.stop(0, Color('#e63946'));
  g.stop(1, Color('#2a9d8f'));
};
smooth.interpolation = 'oklch';
smooth.steps = 15;
```

### Conic Gradient CSS Variable Limitation

Conic gradients are rasterized at compile time (Canvas 2D in the playground, wedge-path approximation in the CLI). This means `Color(CSSVar(...))` stops in conic gradients are **baked out** — the fallback color is extracted and used directly in the rasterized output.

Unlike linear and radial gradients, which use native SVG elements with live `var()` references, conic gradients will **not** update when CSS custom properties change at runtime.

Unfortunately, live-updating CSS variable colors is only available in the playground at this time. The compiler emits a warning when conic gradients contain CSSVar stops.

### Conic Gradient Inheritance

Use `.inherit(newId)` to create child conic gradients. All conic-specific properties (`from`, `to`, `direction`, `spread`, `innerRadius`, `innerFill`) propagate to the child:

```
let child = wheel.inherit('child-wheel');
child.from = 90deg;
```

## Gradient Inheritance

Create a new gradient that inherits stops and attributes from an existing one using `.inherit(newId)`:

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

let rotated = base.inherit('rotated');
rotated.gradientTransform = 'rotate(90, 0.5, 0.5)';
```

The inherited gradient uses SVG's `href` attribute to reference the parent. It inherits all stops and attributes from the parent, and you can override specific attributes on the child. Inherited gradients with no stops of their own produce self-closing elements.

## Property Access

| Expression | Returns |
|------------|---------|
| `gradient.id` | The gradient's string ID |
| `gradient.spreadMethod` | Current spreadMethod or `undefined` |
| `gradient.gradientUnits` | Current gradientUnits or `undefined` |
| `gradient.gradientTransform` | Current gradientTransform or `undefined` |
| `gradient.interpolation` | Current interpolation mode or `null` |
| `gradient.steps` | Current steps value or `null` |
| `gradient.from` | Conic: start angle in radians (default `0`) |
| `gradient.to` | Conic: end angle in radians (default `2π`) |
| `gradient.direction` | Conic: `'cw'` or `'ccw'` (default `'cw'`) |
| `gradient.spread` | Conic: spread mode (default `'clamp'`) |
| `gradient.innerRadius` | Conic: center plateau radius in pixels (default `0`) |
| `gradient.innerFill` | Conic: inner fill mode — `'transparent'`, `'transparent-blend'`, `'center'`, or Color value |
| `pattern.id` | The pattern's string ID |
| `pattern.patternUnits` | Current patternUnits or `null` |
| `pattern.patternTransform` | Current patternTransform or `null` |
| `pattern.patternContentUnits` | Current patternContentUnits or `null` |

## Dynamic Stop Generation

Use loops and expressions inside the trailing block for programmatic stops:

```
let ramp = LinearGradient('ramp', 0, 0, 1, 0) {|g|
  let colors = ['#e63946', '#f4a261', '#2a9d8f', '#264653', '#e9c46a'];
  for ([color, i] in colors) {
    g.stop(calc(i / 4), Color(color));
  }
};
```

Any statement valid in the language can appear inside the block — `for` loops, `if` statements, `let` bindings, function calls, etc.

## SVG Output

The compiler produces gradient definitions in the `<defs>` section:

```xml
<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`:

```xml
<linearGradient id="rotated" href="#base" gradientTransform="rotate(90, 0.5, 0.5)"/>
```

## Output Format

When using the JavaScript API, gradients appear in `result.gradients`:

```js
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

```pathogen
// 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:

```pathogen
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.

```pathogen
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.

```pathogen
let s = @{ circle(0, 0, 80); closePath() };
let topo = TopoGradient('terrain', 400, 300) {|g|
  g.contour(s.project(200, 150), 0.3, Color('#2ecc71'))
  g.contour(s.project(200, 150, 0.5, 0.5), 0.7, Color('#e74c3c'))
};
topo.method = 'laplace';
topo.iterations = 300;
topo.baseColor = Color('#1a5276');
```

### Rendering

TopoGradient is rasterized per-pixel:

- **Playground**: WebGPU shader with SDF computation (fast); Canvas 2D fallback on Firefox/Safari (slower)
- **CLI**: Warning emitted, solid-color approximation rendered

### Error Handling

| Error | Cause |
|-------|-------|
| `TopoGradient() expects 3 arguments` | Wrong argument count |
| `TopoGradient() first argument must be a string` | Non-string ID |
| `TopoGradient() width and height must be numbers` | Non-numeric dimensions |
| `.contour() expects 3 arguments` | Wrong argument count |
| `.contour() first argument must be a ProjectedPathValue` | Non-projected path |
| `.contour() elevation must be between 0 and 1` | Out-of-range elevation |
| `.contour() third argument must be a Color value` | Non-Color color |
| `.contour() path must be closed` | Path not ending with closePath() |
| `TopoGradient easing must be one of: ...` | Invalid easing value |
| `TopoGradient iterations must be a number` | When setting iterations to a non-number |
| `TopoGradient iterations must be between 1 and 2000` | When iterations is out of range |

---

# CSSVar Type

`CSSVar` creates CSS custom property references (`var()`) that can be used in style blocks. This lets SVGs generated by Pathogen be parameterized by the consuming page's CSS.

## Constructor

```
let v = CSSVar('--primary');                    // no fallback
let v = CSSVar('--primary', '#e63946');         // string fallback
let v = CSSVar('--primary', Color('#e63946'));  // Color fallback
```

The variable name must start with `--`. The optional fallback can be a plain string or a `Color` value (which auto-converts to its CSS representation).

## Properties

| Property | Type | Description |
|----------|------|-------------|
| `.var` | string | The variable name (e.g. `--primary`) |
| `.fallback` | string or null | The fallback value, or null if none |
| `.css` | string | The full `var()` expression |

```
let v = CSSVar('--primary', '#e63946');
log(v.var);       // --primary
log(v.fallback);  // #e63946
log(v.css);       // var(--primary, #e63946)
```

## Style Blocks

CSSVar values auto-convert in style blocks — no `.css` needed:

```
let fg = CSSVar('--foreground', '#333');

define PathLayer('main') ${ stroke: fg; fill: CSSVar('--fill', 'none'); }
```

This produces `stroke="var(--foreground, #333)"` and `fill="var(--fill, none)"` in the SVG output.

## Composition with Color

CSSVar composes with the Color type for typed fallbacks:

```
let brand = Color('#e63946');
let fg = CSSVar('--primary', brand);
// fg.css → var(--primary, #e63946)
```

## Display

`log()` displays CSSVar values in constructor form:

```
let v = CSSVar('--primary', '#e63946');
log(v);  // CSSVar(--primary, #e63946)

let v2 = CSSVar('--bg');
log(v2);  // CSSVar(--bg)
```

Template literals also use this form:

```
let v = CSSVar('--primary', '#e63946');
log(`color: ${v}`);  // color: CSSVar(--primary, #e63946)
```

---

# Markers

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

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

## Creating a Marker

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

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

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

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

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

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

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

## Appending Paths

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

```
m.append(arrow, ${ fill: context-stroke; });
```

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

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

## Using Markers in Styles

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

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

- **`marker-start`** — rendered at the first vertex of the path.
- **`marker-mid`** — rendered at every interior vertex (not the first or last).
- **`marker-end`** — rendered at the last vertex of the path.

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

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

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

## Default Attribute Values

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

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

## Mutable Properties

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

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

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

// Enum override: align the reference point to the marker's right edge,
// size the marker in absolute user-space units, and flip the start-marker
arrowMarker.refX = MarkerRefX.Right;
arrowMarker.markerUnits = MarkerUnits.UserSpaceOnUse;
arrowMarker.orient = MarkerOrient.AutoStartReverse;
```

| Property | Accepts | Enum |
|----------|---------|------|
| `viewBox` | string (`"minX minY width height"`) | — |
| `refX` | number **or** enum value | `MarkerRefX` (`Left`, `Center`, `Right`) |
| `refY` | number **or** enum value | `MarkerRefY` (`Top`, `Center`, `Bottom`) |
| `markerUnits` | enum value | `MarkerUnits` (`StrokeWidth`, `UserSpaceOnUse`) |
| `orient` | number (radians) **or** enum value | `MarkerOrient` (`Auto`, `AutoStartReverse`) |
| `preserveAspectRatio` | enum value | `MarkerPreserveAspectRatio` — `None`, or `{XMin,XMid,XMax}{YMin,YMid,YMax}{Meet,Slice}` (e.g. `XMidYMidMeet`, the default; `XMinYMinSlice`) |

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

## Orient

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

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

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

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

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

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

## `context-stroke` and `context-fill`

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

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

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

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

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

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

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

## Multiple Markers on One Path

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

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

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

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

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

## Generated SVG Output

The basic arrow example above produces:

```xml
<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](#markers-mutable-properties)) |
| `.refY` | number or string | Reference point Y |
| `.markerUnits` | string | Current `markerUnits` value |
| `.orient` | number or string | Current orient (radians if numeric) |
| `.preserveAspectRatio` | string | Current `preserveAspectRatio` value |

## Methods

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

## Auto-Wrapping

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

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

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

## Error Handling

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

---

# Masks and Clip Paths

Masks and clip paths are SVG `<defs>` elements that control visibility of layers. They're created with `Mask()` and `ClipPath()` constructors and referenced from layer style blocks.

## Masks

A mask uses luminance to control visibility — white areas are fully visible, black areas are hidden, and gray values create partial transparency.

### Creating a Mask

```
let m = Mask('my-mask');
```

The argument is the mask's ID string. IDs must be unique across all masks and clip paths.

### Appending Paths

Use `.append(path, styles?)` to add path elements to the mask:

```
let base = @{ m 0 0 l 200 0 l 0 200 l -200 0 z };
let hole = @{ m 50 50 l 100 0 l 0 100 l -100 0 z };

let m = Mask('reveal');
m.append(base, ${ fill: white; });    // visible area
m.append(hole, ${ fill: black; });    // hidden cutout
```

The first argument accepts either a `PathBlock` or a `ProjectedPath`. PathBlocks are automatically projected at the origin (0, 0). The optional second argument is a style block for the path element.

### Using a Mask

Reference the mask from a layer's style block using the `.id` property:

```
define PathLayer('art') ${ mask: m.id; }
layer('art').apply {
  M 10 10 L 190 190
}
```

The `mask` property automatically wraps the ID with `url(#...)`, so `m.id` (which returns `'reveal'`) becomes `mask: url(#reveal)` in the output.

### Full Example

```
// Define mask geometry
let fullRect = @{ m 0 0 l 200 0 l 0 200 l -200 0 z };
let circle = @{ m 100 50 a 50 50 0 1 1 0 100 a 50 50 0 1 1 0 -100 };

// Create mask: white = visible, black = hidden
let m = Mask('circle-reveal');
m.append(fullRect, ${ fill: black; });
m.append(circle, ${ fill: white; });

// Apply mask to layer
define PathLayer('drawing') ${ mask: m.id; stroke: #333; stroke-width: 2; }
layer('drawing').apply {
  for (i in 0..20) {
    M 0 calc(i * 10)
    L 200 calc(i * 10)
  }
}
```

This draws horizontal lines that are only visible inside the circular mask.

## Clip Paths

A clip path uses geometry to clip content — anything inside the path is visible, everything outside is hidden. Unlike masks, clip paths don't use styles (they're purely geometric).

### Creating a Clip Path

```
let c = ClipPath('my-clip');
```

### Appending Paths

Use `.append(path)` to add path elements. No styles parameter — clip paths are geometry-only:

```
let shape = @{ m 20 20 l 160 0 l 0 160 l -160 0 z };
let c = ClipPath('frame');
c.append(shape);
```

### Using a Clip Path

```
define PathLayer('scene') ${ clip-path: c.id; }
layer('scene').apply {
  M 0 0 L 200 200
}
```

Like masks, the `clip-path` property automatically wraps the ID with `url(#...)`.

## Auto-Wrapping

The following CSS properties automatically wrap bare ID strings with `url(#...)`:

- `mask`
- `clip-path`
- `filter`
- `marker-start`
- `marker-mid`
- `marker-end`

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

```
// These produce the same output:
define PathLayer('a') ${ mask: m.id; }         // m.id → 'my-mask' → url(#my-mask)
define PathLayer('b') ${ mask: url(#my-mask); } // already wrapped, left as-is
```

## Properties

| Property | Returns | Description |
|----------|---------|-------------|
| `.id`    | string  | The raw ID string passed to the constructor |

## Methods

| Method | Applies To | Description |
|--------|-----------|-------------|
| `.append(path, styles?)` | Mask | Add a path element with optional styles |
| `.append(path)` | ClipPath | Add a path element (no styles) |

## Error Handling

- Duplicate IDs across masks and clip paths throw an error
- Constructor requires exactly one string argument
- `.append()` requires a PathBlock or ProjectedPath as the first argument
- Mask `.append()` requires a style block as the optional second argument

---

# Filters

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

Pathogen ships six custom filter constructors:

- [`NoiseFilter`](#filters-noisefilter) — grain, paper, speckle, TV static, grainy gradient
- [`GlowFilter`](#filters-glowfilter) — soft outer or inner glow
- [`EmbossFilter`](#filters-embossfilter) — light-source-based embossed surface
- [`ElevationShadowFilter`](#filters-elevationshadowfilter) — Material-style layered depth shadow
- [`InnerShadowFilter`](#filters-innershadowfilter) — inset shadow (no native CSS equivalent)
- [`PixelateFilter`](#filters-pixelatefilter) — mosaic / pixelation

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

## NoiseFilter

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

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

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

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

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

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

## NoiseFilterStyle Presets

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

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

### Per-Style Defaults

The chain shape is the same across all five styles (see [Generated SVG Output](#filters-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](#filters-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; }`:

```xml
<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; }`:

```xml
<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); }`:

```xml
<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; }`:

```xml
<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)`:

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

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

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

## BlendMode

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

| Member | CSS keyword |
|---|---|
| `BlendMode.Normal` | `normal` |
| `BlendMode.Multiply` | `multiply` |
| `BlendMode.Screen` | `screen` |
| `BlendMode.Overlay` | `overlay` |
| `BlendMode.ColorBurn` | `color-burn` |
| `BlendMode.ColorDodge` | `color-dodge` |
| `BlendMode.HardLight` | `hard-light` |
| `BlendMode.SoftLight` | `soft-light` |
| `BlendMode.Darken` | `darken` |
| `BlendMode.Lighten` | `lighten` |
| `BlendMode.Difference` | `difference` |
| `BlendMode.Exclusion` | `exclusion` |

```
let custom = NoiseFilter() {|f|
  f.style = NoiseFilterStyle.Paper;
  f.blend = BlendMode.SoftLight;
};
```

## GlowMode

`GlowMode` selects the kind of glow a `GlowFilter` produces.

| Member | String value | Effect |
|---|---|---|
| `GlowMode.Outer` | `outer` | Glow halo extends outward from the painted silhouette |
| `GlowMode.Inner` | `inner` | Glow rides along the inside edge of the painted silhouette |

```
let halo = GlowFilter() {|f|
  f.mode = GlowMode.Outer;
  f.color = oklch(85% 0.20 60);
  f.radius = 10;
};

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

## Using a Filter in a Style Block

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

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

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

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

## Layering with Native CSS Filters

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

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

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

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

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

## Pairing with Gradients

Custom filters compose cleanly with every [gradient](./gradients.md) 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](./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`):

```xml
<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](#filters-seed-stability) for how to lock the seed across edits.

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

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

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

## Browser Caveats

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

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

## Recipes

#### Filmic portrait grain

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

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

#### Heavy paper texture

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

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

#### Risograph speckle

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

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

#### Subtle TV static

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

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

#### High-contrast grainy gradient

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

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

#### Warm outer glow

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

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

#### Inner edge light

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

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

#### Soft emboss

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

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

#### Material card shadow

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

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

#### Pressed button

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

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

#### Chunky pixelation

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

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

## Error Handling

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

## See Also

- [Gradients](./gradients.md) — pair `NoiseFilter` with linear, radial, conic, mesh, freeform, or topo gradients
- [Layers](./layers.md) — `GroupLayer` composition for stacking custom and native CSS filters
- [Markers](./markers.md) — another defs-producing constructor following the same trailing-block convention
- [Syntax](./syntax.md) — style blocks, trailing blocks, and template-literal interpolation

---

# Grid

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

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

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

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

## Creating a Grid

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

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

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

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

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

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

## Constructor options

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

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

## Members

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

## Cell access

### `get(row, col)`

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

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

### `set(row, col, value)`

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

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

### `getPoint(row, col)`

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

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

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

### `getRow(row)` and `getCol(col)`

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

### `cells()`

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

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

## Iteration

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

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

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

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

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

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

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

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

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

## Sampling at arbitrary positions

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

### `sampleNearest(x, y)`

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

### `sampleBilinear(x, y)`

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

### `sample(x, y)`

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

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

## Example: a flow-field arrow grid

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

> **Two cell representations for flow fields, choose by use case:**
> - **Storing scalar angles** (this section) — fine when you only render at cell centers via `forEach`. Simpler to write and reason about.
> - **Storing unit vectors** `Point(cos(a), sin(a))` (see [the bilinear-sampling primer below](#bilinear-sampling--what-it-is-and-when-to-use-it)) — 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):

```text
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:

```text
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](markers.md) — uses the same trailing-block construction pattern.
- [Path Blocks](path-blocks.md) — `rotateAtVertexIndex` and `drawTo` are the natural way to render arrows at each cell.
- [Gradients](gradients.md) — `MeshGradient` 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`](stdlib.md) — produce SVG path data for visual lattices; not data containers.

---

# Objects

Objects are key-value containers for grouping related data — coordinates, configuration, metadata, or any structured values.

## Object Literals

Create objects with curly braces and `key: value` pairs:

```
let obj = {};
let point = { x: 50, y: 80 };
let config = { name: 'Dave', age: 32, cats: ['foo', 'bar', 'baz'] };
```

Keys can be identifiers or string literals. Trailing commas are allowed.

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

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

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

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

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

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

Keys can also be identifiers or string literals. Trailing commas are allowed:

```
let obj = {
  'first-name': 'Alice',
  lastName: 'Smith',
  age: 30,
};
```

Objects can be nested:

```
let shape = {
  center: { x: 100, y: 100 },
  radius: 50,
};
```

## Reading Properties

**Dot notation** — for identifier keys:

```
let x = point.x;       // 50
let r = shape.radius;   // 50
```

**Bracket notation** — for any string key, including dynamic expressions:

```
let x = point['x'];     // 50

let key = 'name';
let val = config[key];   // 'Dave'
```

Accessing a key that doesn't exist returns `null`:

```
let missing = point.z;       // null
let also = point['nope'];    // null
```

The `length` property returns the number of keys:

```
let size = point.length;  // 2
```

## Writing Properties

Use bracket notation to set or update properties:

```
let obj = {};
obj['x'] = 10;
obj['y'] = 20;
obj['x'] = 99;  // overwrite
```

This also works for updating array elements:

```
let arr = [1, 2, 3];
arr[0] = 99;  // arr is now [99, 2, 3]
```

## Checking Key Existence

Use `.has()` to check if a key exists:

```
let obj = { name: 'Alice' };
if (obj.has('name')) {
  // true
}
if (obj.has('age')) {
  // false
}
```

## Object Namespace Functions

The `Object` namespace provides utility functions:

### Object.keys(obj)

Returns an array of all keys:

```
let obj = { a: 1, b: 2, c: 3 };
let keys = Object.keys(obj);  // ['a', 'b', 'c']
```

### Object.values(obj)

Returns an array of all values:

```
let vals = Object.values(obj);  // [1, 2, 3]
```

### Object.entries(obj)

Returns an array of `[key, value]` pairs:

```
let entries = Object.entries(obj);  // [['a', 1], ['b', 2], ['c', 3]]
```

### Object.delete(obj, key)

Removes a key from the object. Returns the deleted value, or `null` if the key didn't exist:

```
let obj = { x: 10, y: 20 };
let deleted = Object.delete(obj, 'x');  // 10
// obj is now { y: 20 }
```

## Iterating Over Objects

### Keys only

```
let obj = { x: 10, y: 20 };
for (key in obj) {
  log(key);  // 'x', then 'y'
}
```

### Key-value pairs

```
for ([key, value] in obj) {
  log(key, value);  // 'x' 10, then 'y' 20
}
```

This also works with `Object.entries()`:

```
for ([key, value] in Object.entries(obj)) {
  log(key, value);
}
```

## Reference Semantics

Objects use reference semantics (like arrays). Assigning an object to another variable shares the same underlying data:

```
let a = { x: 1 };
let b = a;
b['x'] = 99;
log(a.x);  // 99 — both a and b point to the same object
```

## Merging Objects (`<<`)

The `<<` operator creates a new object by merging two objects together. Properties from the right side override those on the left:

```
let a = { x: 1, y: 2 };
let b = { y: 99, z: 3 };
let merged = a << b;
log(merged);  // {x: 1, y: 99, z: 3}
```

The original objects are not modified:

```
log(a);  // {x: 1, y: 2} — unchanged
```

Multiple merges can be chained (evaluated left-to-right):

```
let defaults = { stroke: 'black', width: 2, fill: 'none' };
let theme = { stroke: 'red' };
let overrides = { width: 4 };
let final = defaults << theme << overrides;
// {stroke: 'red', width: 4, fill: 'none'}
```

Merge is shallow — nested objects are shared by reference, not deep-copied:

```
let inner = { val: 1 };
let a = { nested: inner };
let b = a << {};
b.nested['val'] = 99;
log(a.nested.val);  // 99 — same inner object
```

## Destructuring

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

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

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

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

Rename properties with `key: localName` syntax:

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

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

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

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

## Using Objects with Path Commands

Objects are natural containers for coordinates and configuration:

```
let start = { x: 10, y: 20 };
let end = { x: 180, y: 160 };

M start.x start.y
L end.x end.y
```

---

# Debug & Console

The playground includes debugging tools to help you understand how your code executes and inspect values during evaluation.

## Console Output

Click the **Console** button in the header to view debug output.

## log() Function

Use `log()` to inspect values during execution:

```
log("message")           // String message
log(myVar)               // Variable with label
log("pos:", ctx.position) // Multiple args
log(ctx)                 // Full context object
```

### Output Format

String arguments display as-is. Other expressions show a label with the source:

```
log("radius is", r)
// Output:
// radius is
// r = 50
```

Objects are expandable in the console - click the arrow to explore nested properties.

## ctx Object

The `ctx` object tracks path state during evaluation:

### ctx.position

Current pen position after the last command.

| Property | Description |
|----------|-------------|
| `ctx.position.x` | X coordinate |
| `ctx.position.y` | Y coordinate |

```
M 100 50
log(ctx.position)  // {x: 100, y: 50}
L 150 75
log(ctx.position)  // {x: 150, y: 75}
```

### ctx.start

Subpath start position (set by `M`/`m`, used by `Z`).

| Property | Description |
|----------|-------------|
| `ctx.start.x` | X coordinate |
| `ctx.start.y` | Y coordinate |

### ctx.commands

Array of all executed commands with their positions:

```
// Each entry contains:
{
  command: "L",        // Command letter
  args: [150, 75],     // Evaluated arguments
  start: {x: 100, y: 50},
  end: {x: 150, y: 75}
}
```

## Using ctx in Paths

Access position values with `calc()`:

```
M 50 50
// Draw relative to current position
L calc(ctx.position.x + 30) ctx.position.y
circle(ctx.position.x, ctx.position.y, 5)
```

## Example: Debug a Loop

```
M 20 100
for (i in 0..4) {
  log("iteration", i, ctx.position)
  L calc(ctx.position.x + 40) 100
}
```

This logs the iteration number and current position at each step, helping you trace how the path is constructed.

---

# CLI Reference

The pathogen-lang CLI compiles extended SVG path syntax into standard SVG path strings or complete SVG files.

## Installation

```bash
npm install -g pathogen-lang
```

Or use with npx:

```bash
npx pathogen-lang [options]
```

## Basic Usage

### Compile a File

```bash
pathogen-lang input.svgx
```

Or with the explicit flag:

```bash
pathogen-lang --src=input.svgx
```

### Compile Inline Code

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

### Read from Stdin

```bash
echo 'let x = 50; circle(x, x, 25)' | pathogen-lang -
```

```bash
cat myfile.svgx | pathogen-lang -
```

## Output Options

### Output Path Data to File

```bash
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:

```bash
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

```bash
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:

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

### Write structured JSON

```bash
pathogen-lang --src=input.pathogen --log-file=logs.json
```

This writes the full `LogEntry[]` array with line numbers and typed parts:

```json
[
  {
    "line": 3,
    "parts": [
      { "type": "value", "label": "x", "value": "42" }
    ]
  }
]
```

Both flags can be combined:

```bash
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

```bash
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

```bash
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:

```bash
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:

```bash
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

```bash
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`](#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:

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

Blue filled polygon:

```bash
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:

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

## Help and Version

```bash
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

```bash
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

```bash
for file in examples/*.svgx; do
  pathogen-lang --src="$file" --output-svg-file="${file%.svgx}.svg"
done
```

### Use in a Build Script

```json
{
  "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*](https://muffin.ink/blog/scratch-svg-sanitization/) catalogs eleven classes of attacks against the Scratch project's SVG pipeline, several of which apply to any system that emits user-influenced SVG. Pathogen's contract closes those attack classes at the producer.

## Compiler contract

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

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

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

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

## What's allowed in `style { … }` blocks

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

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

What's **rejected**:

```pathogen
# 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](cssvar.md). The compiler will emit a properly-formed `var(--name, fallback)` reference for you.

## What's allowed as identifiers

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

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

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

## What `SVGDocumentFragment()` rejects

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

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

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

## Playground & blog rendering

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

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

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

## Reporting a vulnerability

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

---

# Publishing Workspaces

Pathogen Studio's [Explore](/explore) and [Featured](/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](/explore) at a permanent URL under your handle. If the reviewer also chooses to feature it, the workspace appears on [Featured](/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.

