Decorators

Spytial decorators describe what should hold about a layout — not how to render it. They're declarative constraints, attached to a type once with an attribute, applied everywhere a value of that type appears.

A decorator like #[align(selector = "reports_to", direction = "horizontal")] does not say "draw an arrow from A to B." It says "atoms related by reports_to should line up horizontally." The layout engine picks the actual coordinates, as long as the constraint holds. Three things follow:

  • They compose. Add an orientation rule, then a color, then a hide rule — the diagram refines step by step. No rule has to know about any other.
  • They're per-type, not per-instance. Decorate RBNode once; every RBNode in the value picks up the same rules, however deeply nested.
  • They're registered and collected transitively. Decorating Person is enough for those rules to apply wherever Person appears inside a Vec<T>, Option<T>, Box<T>, or their nested combinations.

A red-black tree, one rule at a time

The default layout gets you most of the way for trees and lists; a few decorators do the rest. This is the full examples/rbt.rs demo, built up stage by stage — each snippet is an attribute you add to the struct.

Stage 1 — no decorators. Debug + Serialize is enough for a correct but flat structural graph; you can't read it as a tree yet.

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize)]
struct RBNode {
    key: u32,
    color: Color,
    left: Option<Box<RBNode>>,
    right: Option<Box<RBNode>>,
}
}

Stage 2 — show the key. Add the derive and the first decorator. Each RBNode atom now carries its key as a label, so you can see what's where.

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize, SpytialDecorators)]
#[attribute(field = "key")]
}

Stage 3 — make it a tree. Left children go down-and-left, right children down-and-right; the layout now encodes BST order.

#![allow(unused)]
fn main() {
#[orientation(selector = "{x, y : RBNode | x->y in left}",  directions = ["left",  "below"])]
#[orientation(selector = "{x, y : RBNode | x->y in right}", directions = ["right", "below"])]
}

The selector reads: "for any pair (x, y) of RBNodes where x -> y is in the left relation, place y to the left of and below x."

Stage 4 — make it a red-black tree. Color nodes by their color field. The pattern matches any matching node, not specific instances.

#![allow(unused)]
fn main() {
#[atom_style(selector = "{x : RBNode | @:(x.color) = \"Red\"}",   border_style(color = "red"))]
#[atom_style(selector = "{x : RBNode | @:(x.color) = \"Black\"}", border_style(color = "black"))]
}

\"Red\" is a string literal in the query, escaped because the selector is itself a Rust string. Quoting matters: a bare name that resolves to nothing is the empty relation, so the comparison would just be false and the rule would quietly never fire — the diagram still renders, only unstyled. The diagram flags this with a ⚠ n selector warnings bar naming the decorator and its selector.

A raw string carries the same query without the backslashes, if you prefer to read the selector as the query engine sees it:

#![allow(unused)]
fn main() {
#[atom_style(selector = r#"{x : RBNode | @:(x.color) = "Red"}"#, border_style(color = "red"))]
}

Stage 5 — hide the scaffolding. The Color enum atoms, the u32 keys, and the None sentinels are already implied by node color, labels, and absent edges. Drop them from the canvas.

#![allow(unused)]
fn main() {
#[hide_atom(selector = "Color + u32 + None")]
}

Color + u32 + None is a set expression: match any atom whose type is Color, u32, or the literal None. Each rule refined an existing structure rather than imposing an external aesthetic.

Attribute reference

Every decorator is a Rust attribute on a type that derives SpytialDecorators. They group into three families.

Display

AttributeWhat it does
#[attribute(field = "...", selector = "...", filter = "...")]Promote a field's value into the node's label.
#[flag(name = "...")]Set a global display flag. name is required and is one of hideDisconnected, hideDisconnectedBuiltIns.

Layout constraints

AttributeWhat it does
#[orientation(selector = "...", directions = [...])]Place matched pairs in a direction. Required; each value is one of "above", "below", "left", "right", or a "directly*" variant. above/below and left/right are mutually exclusive, and a directly* value admits only its own plain counterpart alongside it.
#[align(selector = "...", direction = "horizontal" | "vertical")]Force matched atoms to share an axis.
#[cyclic(selector = "...", direction = "clockwise" | "counterclockwise")]Arrange matched atoms around a ring. direction defaults to clockwise.
#[group(selector = "...", name = "...", show_label = ...)]Cluster related atoms into a region. A binary selector builds one group per distinct first-column atom, keyed by it; a unary selector builds a single unkeyed group. Set show_label = false to hide the group caption and its background pill while keeping the hull.

orientation, align, cyclic, and group each take an optional negated = true — see Negated constraints below.

Styling, filtering, and overrides

Styling follows spytial-core's block system, introduced in 3.x. Blocks are written as nested groups that mirror the YAML 1:1:

  • line_style(color = "...", pattern = "solid" | "dashed" | "dotted", weight = 2.0, highlight = "...") — a drawn edge line;
  • text_style(size = "small" | "normal" | "large", color = "...") — any label;
  • border_style(color = "...", width = 2.0) / fill_style(color = "...") — an atom's outline and interior;
  • icon_style(path = "...", placement = "full" | "badge", opacity = 0.4) — an atom's icon.

Every block field is optional — set only what you mean.

Which keys each attribute accepts, and which values are legal, are generated from spytial-core's own language manifest (see spec-codegen/). A key or value this crate rejects is one spytial-core would reject or silently ignore, so typos, unknown block leaves, out-of-vocabulary values, and out-of-range numbers are all compile errors rather than a diagram that renders without them.

AttributeWhat it does
#[atom_style(selector = "...", border_style(...), fill_style(...), icon_style(...), text_style(...), show_label = ...)]Style matched atoms' border, interior fill, icon, and label independently.
#[size(selector = "...", height = ..., width = ...)]Override node dimensions (in diagram units).
#[icon(selector = "...", path = "...", show_labels = ...)]Deprecated by spytial-core 4.2; rewrites to atom_style. Its single show_labels boolean drove label visibility and icon geometry at once — prefer icon_style(...) plus show_label, which are independent.
#[edge_style(field = "...", line_style(...), text_style(...), show_label = ..., hidden = ...)]Style relation arrows: the drawn line, the edge's label, and visibility.
#[hide_field(field = "...", selector = "...", filter = "...")]Suppress a relation from the rendering.
#[hide_atom(selector = "...")]Suppress matched atoms entirely.
#[inferred_edge(name = "...", selector = "...", draw = "...", line_style(...), text_style(...))]Define a synthetic edge derivable from the data, optionally styled. draw attaches its ends to group hulls — see below.
#[tag(to_tag = "...", name = "...", value = "...", text_style(...))]Attach a computed attribute to matched atoms.
#[attribute(field = "...", text_style(...))](See Display above; text_style styles the attribute's line.)

An inferred_edge additionally takes draw = "<end> -> <end>", which decides what each end of the edge attaches to. Each end is either _ — the tuple's own atom, which is what an edge without draw does — or the name of a group constraint, in which case the end lands on that group's hull:

#![allow(unused)]
fn main() {
#[group(selector = "region", name = "regions")]
// One edge per `connected` pair, drawn hull to hull.
#[inferred_edge(name = "connected", selector = "connected", draw = "regions -> regions")]
// Each person to the hull of the region-group they manage.
#[inferred_edge(name = "manages", selector = "manages", draw = "_ -> regions")]
}

draw never decides which pairs get edges or which way they point — the selector does (transpose it, e.g. ~connected, to flip one). A keyed group constraint builds one group per key and the end's atom picks which; a unary one builds a single group the end attaches to directly. With draw, the selector may also be unary: the single atom feeds both ends.

Malformed draw strings are compile errors, including the redundant "_ -> _" (that's the default — drop the key). The group name, though, is resolved by spytial-core against the assembled spec: decorators compose across types, so no single attribute site can see which group constraints will end up in the spec. A name that no group defines is a layout-time warning (since spytial-core 5.1.0) and that edge is skipped; the rest of the diagram still renders.

A selector-based group additionally takes add_edge — the connector between the group's key and the group. Bare form add_edge = "togroup" (or "fromgroup"/"none"), or the styled block add_edge(points = "togroup", line_style(...), text_style(...)) — plus a top-level text_style(color = "...") for the group's own label.

Migrating from the 2.x flat forms: #[atom_color(selector, value)] and #[edge_style(field, value, style, weight, ...)] still compile and are rewritten onto the block forms — atom_color's value becomes the border colour (that is what 2.x drew; reach for fill_style only if you want a filled look), and edge_style's value/style/weight become line_style's color/pattern/weight. Mixing flat keys and blocks in one edge_style is a compile error.

Deprecation warnings: every form spytial-core has deprecated now warns at compile time, naming the replacement and how the fields map across. That covers whole attributes (#[atom_color], #[icon]) and single shapes of attributes that are otherwise current — #[edge_style(value = ...)] warns while #[edge_style(field = ..., line_style(...))] does not. Nothing stops compiling; the deprecated forms still work exactly as before. To keep one deliberately, put #[allow(deprecated)] on the type:

#[derive(Serialize, SpytialDecorators)]
#[allow(deprecated)]
#[atom_color(selector = "Node", value = "red")]
struct Node { /* ... */ }

The warning text comes from spytial-core's own language manifest, so it moves when upstream's does.

Breaking in spytial-core 3.0: two style rules that set the same property of the same edge/atom to different values now raise a StyleCollisionError at render time (2.x silently kept the first). Set each property in exactly one matching rule.

These map onto the same builder and YAML layer that spytial-core consumes, so anything expressible here is also expressible as a hand-written spec passed to diagram_with_spec.

Where a rule came from

Every rule the derive emits is stamped with a source block: the attribute exactly as you wrote it and its file:line. When the viewer reports a conflict — two rules that cannot both hold — or warns about a rule that matched nothing, it cites that text in place of its own description of the rule, so the report reads #[orientation(selector = "…", directions = ["left", "below"])] at src/tree.rs:14 and you can go straight to the line. Nothing to enable; a hand-written spec passed to diagram_with_spec needs no source, because there the YAML is what you wrote. The builder's .source(text, location) stamps the rule you pushed last, if you assemble decorators by hand and want the same treatment.

Attribute reference

The reference below is generated from spytial-core's own language manifest, the same file the derive's compile-time checks come from, so the keys, vocabularies, bounds, defaults, and selector arities are the engine's description of the language at the vendored version. It is the same text docs.rs shows on the derive.

Generated from spytial-core 6.4.1's language manifest, so every key, vocabulary, bound, default, and selector arity below is the engine's own description of the language (dated 2026-09-18). Keys are the Rust spellings; negated = true is spytial-core's hold: never. Where a selector lists more than one accepted shape, the first is the one the field is designed for. spytial-core's own guides: YAML reference, constraints, directives, selectors.

Constraints

#[orientation(…)]

Place the two ends of each selected tuple relative to one another. (a layout constraint; takes negated = true)

#[orientation(selector = "parent", directions = ["above"])]
  • selector: selector; required. Returns (source, target) pairs. The constraint applies to each pair. (a bad value makes spytial-core reject the whole spec)
    • binary (2 columns): Each tuple is one (first, last) pair, and the constraint applies to it.
    • n-ary (3 or more columns): Accepted: the pair is the tuple's first and last atom. The columns between are ignored.
  • directions: one or more of above, below, left, right, directlyAbove, directlyBelow, directlyLeft, directlyRight; required. Where the TARGET sits relative to the SOURCE. directions: [above] on selector parent places each tuple's target above its source. The directly* variants additionally enforce axis alignment. The direction is target-relative-to-source. Getting this backwards is the most common spec bug — transpose the selector (~parent) rather than flipping the direction if the relation reads the other way. (a bad value makes spytial-core reject the whole spec)
  • negated: true asserts the relationship must not hold (spytial-core's hold: never).

Contradictory direction sets are rejected at parse time: above with below, left with right, and a directly* variant combined with anything other than its own non-direct counterpart. Values outside the listed set are NOT rejected — they parse and then match nothing.

#[align(…)]

Put the selected atoms on a shared horizontal or vertical line. (a layout constraint; takes negated = true)

#[align(selector = "siblings", direction = "horizontal")]
  • selector: selector; required. Returns the pairs of atoms to align with one another. (a bad value makes spytial-core reject the whole spec)
    • binary (2 columns): Each tuple is one (first, last) pair, and the constraint applies to it.
    • n-ary (3 or more columns): Accepted: the pair is the tuple's first and last atom. The columns between are ignored.
  • direction: one of horizontal, vertical; required. horizontal gives the atoms a shared Y coordinate; vertical a shared X coordinate. An unrecognized value is a parse error. (a bad value makes spytial-core reject the whole spec)
  • negated: true asserts the relationship must not hold (spytial-core's hold: never).

#[cyclic(…)]

Arrange the selected atoms around the perimeter of a circle, in the order the selector defines. (a layout constraint; takes negated = true)

#[cyclic(selector = "nextState", direction = "clockwise")]
  • selector: selector; required. Returns (a, b) pairs meaning "b follows a around the circle". (a bad value makes spytial-core reject the whole spec)
    • binary (2 columns): Each tuple is one (first, last) pair, and the constraint applies to it.
    • n-ary (3 or more columns): Accepted: the pair is the tuple's first and last atom. The columns between are ignored.
  • direction: one of clockwise, counterclockwise; default clockwise. Which way the cycle runs.
  • negated: true asserts the relationship must not hold (spytial-core's hold: never).

Two cyclic constraints on the same selector with different directions is a parse error. An unrecognized direction string is not rejected at parse time.

#[group(…)]

Draw a hull around a set of atoms. For tuples (a, b), (a, c), … the group is keyed by a and contains {b, c, …}. (a layout constraint; takes negated = true)

#[group(selector = "Team.members", name = "Team")]
  • selector: selector; required. First column is the group key, second the members. A unary selector builds a single unkeyed group. A group with neither selector nor field is silently dropped — it matches neither grouping form. That is the one case where omitting this field is not an error but a no-op.
    • binary (2 columns): One group per distinct first atom: the first column is the key, the last the members. The group is named <name>[<key>], which is what a keyed inferredEdge draw end attaches to.
    • n-ary (3 or more columns): As binary — the key is the tuple's first atom and the member its last, ignoring the columns between.
    • unary (1 column): One single group holding every selected atom, with no key. It is named <name> with no [key] suffix, and a draw end naming it attaches there whatever the end's atom is.
  • name: string; required. Display name on the group, and the handle an inferredEdge draw endpoint refers to. Keyed captions use <name>[<key label>], adding the key ID only to distinguish duplicate captions. Required unless the constraint is negated (hold: never), where a name is generated. (a bad value makes spytial-core reject the whole spec)
  • add_edge: one of none, togroup, fromgroup; or the add_edge(…) block, which also styles it (see Style blocks); default none. Draw a connector between the group key and the group: togroup points key → group, fromgroup points group → key, none draws nothing. The legacy boolean true is still accepted and means togroup. (spytial-core drops a bad value silently)
  • show_label: true or false; default true. Whether the group caption and its background pill are drawn. The group hull remains visible.
  • text_style: text_style(…) block. The group's own label. Only color applies today — group labels auto-fit their box, so size is reserved.
  • negated: true asserts the relationship must not hold (spytial-core's hold: never).

#[size(…)]

Set the width and height of matching nodes, in pixels. (a layout constraint)

#[size(selector = "ImportantNode", width = 150, height = 80)]
  • width: number greater than 0; required. Node width in pixels. Required, and must be a number greater than 0. (a bad value makes spytial-core reject the whole spec)
  • height: number greater than 0; required. Node height in pixels. Required, and must be a number greater than 0. (a bad value makes spytial-core reject the whole spec)
  • selector: selector. Which nodes to resize. Omit to resize every node.
    • unary (1 column): The nodes to resize.

hold: never is not supported and is silently ignored.

#[hide_atom(…)]

Remove matching atoms from the diagram. (a layout constraint)

#[hide_atom(selector = "HelperNode")]
  • selector: selector; required. Which atoms to hide.
    • unary (1 column): The atoms to hide.

hold: never is not supported and is silently ignored. Hiding an atom that a layout constraint places, or that a group contains, makes the spec unsatisfiable: the layout reports a hidden-node conflict and draws a counterfactual with the conflicting atoms outlined. Hiding a keyed group's key is fine — the key is not inside the group.

Directives

#[attribute(…)]

Render a relation as a key-value line on its source node instead of as an edge.

#[attribute(field = "age", selector = "Person")]
  • field: relation (field) name; required. The relation to fold into the source node. Its edges are removed from the graph.
  • selector: selector. Apply only to these source atoms. Omit to apply to every source atom.
    • unary (1 column): The set of source atoms the directive is narrowed to.
  • filter: selector. Apply only to the (source, target, …) tuples this selector returns. Use it to scope a directive to part of a relation, e.g. only the tuples whose value is True.
    • n-ary (2 or more columns): A tuple matches on its first and last atom, so a longer tuple filters the same relation ends a pair would. A unary result matches nothing — the directive is then scoped to no tuples at all.
  • text_style: text_style(…) block. This attribute line's own styling.

Multiple targets for the same source become a list. Unlike tag, this removes the edge.

#[flag(…)]

Toggle a whole-diagram rendering behaviour.

#[flag(name = "hideDisconnectedBuiltIns")]
  • name: one of hideDisconnected, hideDisconnectedBuiltIns; required. hideDisconnected hides every atom with no edges; hideDisconnectedBuiltIns hides only the disconnected built-in atoms (Int, String, …). Any other value is silently ignored.

Written as a bare scalar: - flag: hideDisconnected, not a mapping.

#[atom_color(…)]

Set the color of matching atoms. (deprecated, rewrites to #[atom_style])

#[atom_color(selector = "Person", value = "#ff5733")]
  • value: color; required. Any CSS color. Applies to the node's outline, not its fill.
  • selector: selector; required. Which atoms to recolor. Omitting it drops the directive — it never means "every atom".
    • unary (1 column): The atoms to recolor.

#[atom_style(…)]

Style matching atoms. An atom is a composite of an interior fill, an outline, an icon, and its label, so each is its own block.

#[atom_style(selector = "Person", border_style(color = "#0369a1"))]
  • selector: selector. Which atoms to style. Omit to style every atom.
    • unary (1 column): The atoms this rule styles.
  • fill_style: fill_style(…) block. The node's interior fill.
  • border_style: border_style(…) block. The node's outline.
  • icon_style: icon_style(…) block. An icon drawn on the node.
  • text_style: text_style(…) block. The node's own label.
  • show_label: true or false; default true. Whether the atom's label is drawn. Independent of the icon: iconStyle.placement controls the icon's geometry, this controls the label.

Rules compose: a supertype selector already returns subtype atoms, so a Node rule and a RedNode rule both apply to a RedNode atom and their set properties gap-fill each other. Two rules setting the SAME property to DIFFERENT values is a hard error at layout time — styles never silently override.

#[icon(…)]

Assign an icon to matching atoms. (deprecated, rewrites to #[atom_style])

#[icon(selector = "Person", path = "person", show_labels = true)]
  • selector: selector; required. Which atoms get the icon. Omitting it drops the directive entirely — it never means "every atom".
    • unary (1 column): The atoms that get the icon.
  • path: icon name, icon-pack reference, URL, or path; required. Bundled name, icon-pack reference, URL, or path. Omitting it drops the directive.
  • show_labels: true or false; default false. Whether the atom's label is drawn alongside the icon.

#[edge_style(…)]

Style the edges of a relation: the drawn line, the label, and visibility.

#[edge_style(field = "parent", line_style(color = "blue", pattern = "dashed"))]
  • field: relation (field) name; required. The relation whose edges this styles.
  • selector: selector. Apply only to edges whose source atom is in this set. Omit to apply to every source atom.
    • unary (1 column): The set of source atoms the directive is narrowed to.
  • filter: selector. Apply only to the (source, target, …) tuples this selector returns. Use it to scope a directive to part of a relation, e.g. only the tuples whose value is True.
    • n-ary (2 or more columns): A tuple matches on its first and last atom, so a longer tuple filters the same relation ends a pair would. A unary result matches nothing — the directive is then scoped to no tuples at all.
  • line_style: line_style(…) block. The drawn line.
  • text_style: text_style(…) block. The edge label.
  • show_label: true or false; default true. Whether the edge label is drawn.
  • hidden: true or false; default false. Hide the edge entirely while keeping the relation in the data.
  • value: color; the deprecated edgeColor form, which rewrites onto the blocks above; mixing the two shapes is a compile error. Line color.
  • style: one of solid, dashed, dotted; the deprecated edgeColor form, which rewrites onto the blocks above; mixing the two shapes is a compile error. Dash pattern. Trimmed and lowercased before matching, unlike lineStyle.pattern. (spytial-core drops a bad value silently)
  • weight: number greater than 0; the deprecated edgeColor form, which rewrites onto the blocks above; mixing the two shapes is a compile error. Line thickness in pixels. (spytial-core drops a bad value silently)

Like atomStyle, overlapping rules compose and a genuine disagreement is an error rather than a silent override.

#[hide_field(…)]

Hide a relation's edges.

#[hide_field(field = "internal")]
  • field: relation (field) name; required. The relation to hide.
  • selector: selector. Apply only to edges whose source atom is in this set. Omit to apply to every source atom.
    • unary (1 column): The set of source atoms the directive is narrowed to.
  • filter: selector. Hide only the tuples this selector returns.
    • n-ary (2 or more columns): A tuple matches on its first and last atom, so a longer tuple filters the same relation ends a pair would. A unary result matches nothing — the directive is then scoped to no tuples at all.

#[inferred_edge(…)]

Draw an edge that is not in the data, computed from a selector.

#[inferred_edge(name = "reachable", selector = "^parent", line_style(color = "gray", pattern = "dotted"))]
  • name: string; required. The label drawn on the edge.
  • selector: selector; required. Returns the (source, target) pairs to connect. May be unary when draw is given — the single atom then feeds both ends.
    • binary (2 columns): One edge per tuple, from its first atom to its last.
    • n-ary (3 or more columns): One edge from the tuple's first atom to its last. Unlike the constraints, the columns between are not discarded: their labels are appended to the edge label as <name>[m1,m2].
    • unary (1 column); needs draw: One edge per atom, the atom feeding both ends — the shape draw: _ -> <group> needs, connecting each atom to its own group. Without draw a unary selector draws nothing at all: the engine reads this field as tuples, and single-atom tuples are dropped before any edge is made.
  • draw: string. Reinterpret what each end attaches to, as <end> -> <end>. Each end is _ (the atom itself, the default) or the name of a group constraint, in which case the end attaches to that group's hull. The left end applies to the first atom of each tuple, the right end to the last. Parsed strictly: a value that is not a string, or that does not contain exactly one ->, is a parse error. A group name that no group constraint defines is not — it raises a warning (code unresolved-reference, specType inferredEdge) and the edge is skipped at layout time, so a fragment that names a group defined in another fragment still parses. draw never reorders — transpose the selector (~connected) to flip an edge. (a bad value makes spytial-core reject the whole spec)
  • line_style: line_style(…) block. The drawn line.
  • text_style: text_style(…) block. The edge label.

Using any of the legacy inline color/style/weight/highlight keys raises a deprecation warning with specType inferredEdge. The block form wins when both are given. If an end's atom keys no group of that name in a given instance, that one edge is skipped with a console warning — data-dependent, not a spec error.

#[tag(…)]

Add a computed key-value line to matching nodes, without touching the graph structure.

#[tag(to_tag = "Person", name = "age", value = "age")]
  • to_tag: selector; required. Which atoms receive the tag.
    • unary (1 column): The atoms that receive the tag.
  • name: string; required. The literal label shown before the value; this field is not a selector.
  • value: selector; required. Evaluated once; each tuple is attached to the tagged atom in its first column. A binary result shows as name: value; a longer tuple shows one line per tuple, as name[k1,k2]: value. A unary result tags each selected atom with its own label.
    • n-ary (2 or more columns): One line per tuple whose first atom is the tagged atom: the last column is the value and any columns between become one comma-separated key tuple, as name[k1,k2]: value. A binary result is the plain name: value case.
    • unary (1 column): A membership tag: an atom in the result is tagged with its own label, as name: <label>. The value carries no more than "this atom is in the set".
  • text_style: text_style(…) block. This tag line's own styling.

Unlike attribute, tags do not remove edges.

Style blocks

Blocks are written as nested groups that mirror the YAML: line_style(color = "gray", pattern = "dotted"). Every leaf is optional.

line_style(…)

Styling for an edge's drawn line. Reused by edgeStyle, inferredEdge, and a group's addEdge connector.

  • color: color. Any CSS color.
  • pattern: one of solid, dashed, dotted. Dash pattern of the line. (spytial-core drops a bad value silently)
  • weight: number greater than 0. Line thickness in pixels. Must be > 0; a non-positive or non-numeric weight is dropped. (spytial-core drops a bad value silently)
  • highlight: color. A wider, translucent underlay drawn beneath the line. Orthogonal to color and pattern.

text_style(…)

Styling for a label. Reused by every form that renders text — an atom's own label, attribute and tag lines, edge labels, and a group label.

  • size: one of small, normal, large; default normal. Font-size tier relative to the node label: large renders bigger than the label, normal smaller, small smaller still. (spytial-core drops a bad value silently)
  • color: color. Any CSS color. Unset inherits the node label color, so dark mode still adapts.

border_style(…)

An atom's outline.

  • color: color. Any CSS color.
  • width: number greater than 0. Outline thickness in pixels. Must be > 0; anything else is dropped. (spytial-core drops a bad value silently)

fill_style(…)

An atom's interior fill. Opt-in: the default look is unfilled, with only the border and label marking the node.

  • color: color. Any CSS color.

icon_style(…)

An icon drawn on an atom. Every leaf is optional, including path, so a supertype rule can supply the icon and a subtype rule tune only its opacity.

  • path: icon name, icon-pack reference, URL, or path. A bundled icon name (person), an icon-pack reference (bi:person-fill), a URL, or a path. Resolved to a concrete URL or data URI at parse time. An iconStyle with no path draws nothing.
  • placement: one of full, badge; default full. full — the icon occupies the box, which stays transparent unless a fillStyle.color is given. badge — a small marker in the top-right corner, secondary to the label. (spytial-core drops a bad value silently)
  • opacity: number between 0 and 1; default 1. Alpha in [0,1]. Out-of-range values are dropped rather than clamped. Fade a full icon to use it as a watermark behind the label. (spytial-core drops a bad value silently)

add_edge(…) (on #[group])

Block form also styles the connector, which is an edge and so takes the shared edge blocks: { points: <direction>, lineStyle: {…}, textStyle: {…} }.

  • points: one of none, togroup, fromgroup; default none. The connector direction — the same value the bare string form carries.
  • line_style: line_style(…) block. The connector's line.
  • text_style: text_style(…) block. The connector's label.

Deprecated forms

Each still compiles and still works, with a compile-time deprecation warning naming the replacement; #[allow(deprecated)] on the type keeps one deliberately.

  • #[icon(…)] → #[atom_style]. The single showLabels boolean drove label visibility and icon geometry at once. atomStyle splits those into two independent knobs, which is what makes a faded watermark, or a hidden label with no icon, expressible. Mapping: path → icon_style.path; show_labels: false → show_label: false + icon_style.placement: full; show_labels: true → show_label: true + icon_style.placement: badge.
  • #[atom_color(…)] → #[atom_style]. atomStyle expresses the same recolor and also reaches the fill, the icon, and the label. The rewrite is border-preserving, so an existing diagram is unchanged. Mapping: value → border_style.color.
  • #[edge_style] written with value/style/weight → #[edge_style]. edgeStyle groups the same knobs into the shared lineStyle/textStyle blocks that every other form uses. Mapping: value → line_style.color; style → line_style.pattern; weight → line_style.weight; highlight → line_style.highlight.

Negated constraints

orientation, align, cyclic, and group accept negated = true, which flips the constraint from "this must hold" to "this must not hold." It's most useful alongside positive constraints — the positive ones say what shape the layout should take, the negated ones rule out a degenerate arrangement the solver might otherwise pick:

#![allow(unused)]
fn main() {
#[align(selector = "{x, y : Node | x->y in default_edge}", direction = "vertical")]
#[align(selector = "{x, y : Node | x->y in exception_edge}", direction = "vertical", negated = true)]
}

"Default edges align vertically; exception edges must not." A diagram with only negated constraints is under-specified, and the solver falls back to defaults.