# Portable SVG symbols

> Build and share static SVG symbols with safe Ganter bindings and typed connection terminals.

Ganter symbols keep artwork and live data separate. The artwork is ordinary, static SVG. Ganter Lab
owns every value mapping and animation, so an imported file cannot run scripts, event handlers,
embedded animation code or network requests. This page is the complete reference for the authoring
model: what a symbol declares, what the import accepts and refuses, and how symbols travel between
stations.

A symbol declares three things on top of its artwork:

- **Slots** — named inputs (fill, stroke, motion, text, visibility) that a placed
  [dashboard](home) instance later feeds from a Logic address or a fixed value.
- **Terminals** — typed connection points on the perimeter, so the Dashboard can route pipes,
  ducts, wires and shafts between symbols.
- **Suggestions** — optional, explicitly posed compositions with other symbols.

## System symbols and My symbols {#system-symbols-and-my-symbols}

The catalog has two origins, filterable everywhere symbols are listed:

| Origin | Reference | What it is |
| --- | --- | --- |
| System | `system:<stable-key>` | The built-in libraries that ship with the app. Inspectable and exportable, never editable in place. |
| Mine | `user:<guid>` | Symbols you created, imported or duplicated. Editable and deletable. |

Every system symbol offers **Duplicate to My symbols**: the copy is a frozen, independently owned
user asset (artwork, view box, fit, footprint, slots, type configurations, terminals and
suggestions copied together), so it keeps behaving as selected even when a later app update changes
the original. Deleting a user symbol asks for confirmation; dashboards that still reference it show
a placeholder instead of breaking.

## Ways to create a symbol {#ways-to-create-a-symbol}

- **Import a file** — **Logic > Assets > Symbols > Import symbol** accepts a plain `.svg` or a
  complete `.ganter-symbol.json` package. Every import creates a **new** asset with its own
  identity; it never overwrites an existing symbol, and a name collision is resolved by suffixing
  (" 2", " 3", …) rather than failing. The picker refuses files over **1 MB** before any bytes are
  read. After a plain-SVG import the app reports how many unsafe items it removed, if any.
- **Author in the app** — **New user symbol** opens an editor for the name, the SVG markup, the
  dashboard layout (fit and initial footprint) and the slots; once the symbol exists, valid
  changes save automatically. Terminal geometry is intentionally **not** editable in the app:
  typed terminals can only be supplied by a package, and package-authored terminals are preserved
  read-only through later edits.
- **Ask an AI** — two routes share the exact same contract. Over the embedded
  [MCP endpoint](agent), the `symbol_validate` tool dry-runs the import and `symbol_import`
  persists a new asset; both carry the full contract in their descriptions. From an external chat
  with no app access, **Copy chat instruction** in the Symbols panel copies a self-contained
  prompt; save the returned SVG or package as a file and import it here.

See [Logic](logic) for the surrounding assets workspace.

## Prepare a plain SVG {#prepare-a-plain-svg}

Give every part you want to bind a stable SVG `id`. After import, those ids appear in the slot
target pickers. Ids and slot names share one grammar: they start with a letter or `_`, continue
with letters, digits, `_`, `.`, `:` or `-`, and are at most 128 characters.

Optional `data-ganter-*` attributes are the only Ganter-specific markup a symbol may carry:

| Attribute | On | What it declares |
| --- | --- | --- |
| `data-ganter-pivot="x y"` | any bindable element | The exact pivot point (view-box coordinates) for rotate, scale and spin slots. Required on the target element of those three kinds. |
| `data-ganter-spin-ratio` | a child of a spinning element | The relative speed of a visible child rotor. Finite, nonzero, magnitude at most 100; negative spins the other way. Removed unless the element also declares its own pivot. |
| `data-ganter-role` | any element | A stable semantic role for authoring tools. Informational only. |
| `data-ganter-fit-width` | `<text>` / `<tspan>` | Opt-in bounded readout for a Text slot: when the bound text measures wider than this many view-box units, the font shrinks to fit. |
| `data-ganter-fit-min-font-size` | `<text>` / `<tspan>` | The smallest font size the fit may shrink to. Below it, the element shows the fallback while the full value stays in the accessible name and tooltip. |
| `data-ganter-fit-fallback` | `<text>` / `<tspan>` | The text shown when even the minimum font size cannot fit the value. |

Connection terminals do not live in SVG and do not target SVG element ids. Attributes named
`data-ganter-port` or `data-ganter-port-*` are not part of the format: a plain SVG import removes
them; a strict package containing them is rejected.

A plain SVG import always yields a valid **visual-only** symbol: zero terminals, zero slots. The
inspector reports that no connectable terminal exists because role, type, external direction and
internal path were not declared. Ganter does not infer those fields from the file name, category or
artwork, and does not open an internal terminal or path editor.

## What the sanitizer accepts {#what-the-sanitizer-accepts}

The same SVG policy applies to every route (file import, in-app editor, MCP, package). It is
deliberately smaller than SVG:

- **Kept** — inert geometry and structure: `svg`, `g`, `defs`, `symbol`, `use`, `path`, `rect`,
  `circle`, `ellipse`, `line`, `polyline`, `polygon`, `text`, `tspan`, `title`, `desc`,
  `clipPath`, `mask`, `linearGradient`, `radialGradient`, `stop`, `pattern`, `marker`, with
  presentation attributes (fill, stroke, opacity, transform, styling) and ARIA labels.
- **Removed or refused** — `<script>`, `<style>`, `<foreignObject>`, event handlers, embedded
  animation, and any external or `data:`/`file:`/`http(s)` URL. Only internal `url(#id)` and
  `href="#id"` references survive.
- **Limits** — the markup may be at most **512 KB** and **4096 elements**.

Safe paints are hex colors, the CSS named colors, `rgb()`/`rgba()`/`hsl()`/`hsla()`, and the
app's theme tokens `var(--color-<name>)` (for example `var(--color-success)`,
`var(--color-symbol-off)`). Theme tokens are how one artwork adapts to light and dark: ship one
drawing that references tokens, never two theme variants.

How violations are handled depends on the route: a **plain SVG** is repaired (unsafe items are
removed and each removal is reported as a warning); a **package** is strict and is rejected with
the first violations named instead of being silently repaired.

## Portable package v2 {#portable-package-v2}

Exporting a symbol creates a `.ganter-symbol.json` file. Every field of the envelope is required;
the three contract arrays may be empty but must be present:

| Field | Requirement |
| --- | --- |
| `$schema` | Exactly `https://ganterlab.com/schemas/ganter-symbol-v2.schema.json`. |
| `format` | Exactly `ganter-symbol`. |
| `version` | Exactly `2`. Version 1 packages are rejected instead of passed through a legacy parser. |
| `name` | The display name. An import may override it, and a collision is auto-suffixed. |
| `svg` | Sanitized static SVG whose root element carries a `viewBox` equal to the `viewBox` field. |
| `viewBox` | `minX minY width height`: four finite numbers with positive width and height. |
| `fit` | `contain`, `stretch`, `stretch-x` or `stretch-y`. |
| `footprint` | `{ "width": 1..24, "height": 1..12 }` — the suggested dashboard cells. |
| `slots` | Array of slot declarations (below). |
| `typeConfigurations` | Array of shared terminal type choices (below). |
| `terminals` | Array of up to 16 complete terminals (below). |
| `suggestions` | Array of ordered authored compositions (below). |

The public [Ganter symbol v2 JSON Schema](/schemas/ganter-symbol-v2.schema.json) validates the
transport envelope. The importer then performs the semantic checks JSON Schema cannot express:
SVG target existence, internal-path geometry, suggestion compatibility. A package is strict in
every dimension: unsafe markup, unknown fields, null array entries and the former `ports[]`,
`anchors[]`, `profile` and `connectionStyle` contracts are rejected instead of repaired.

The package never contains a station address, a Dashboard binding, a per-instance type choice, a
database GUID or a user identity. A slot that carries a Logic address cannot even be exported: the
address belongs to the placed instance, not the reusable symbol.

## Size, view box and fit {#size-view-box-and-fit}

The **view box** is the symbol's natural coordinate space. Terminals and internal paths are
declared in it, and it must match the SVG root's own `viewBox` exactly; a package whose two view
boxes disagree is rejected. Non-square view boxes are fully supported, so a long conveyor does not
have to live in a square.

The **fit mode** says how the artwork uses the dashboard rectangle the operator draws:

| Fit | Behavior |
| --- | --- |
| `contain` | Keeps the aspect ratio; the artwork letterboxes inside the cell rectangle. The default. |
| `stretch` | Fills the rectangle in both axes, distorting if needed. |
| `stretch-x` | Stretches horizontally, keeps the natural vertical proportion. |
| `stretch-y` | Stretches vertically, keeps the natural horizontal proportion. |

The **footprint** is the initial size in dashboard grid cells when the symbol is first placed:
width 1 through 24, height 1 through 12 (the editor defaults to 4 × 4). It is a suggestion, not a
constraint; the operator resizes freely afterwards.

Two separate gates bound an import's size: the file picker refuses anything over **1 MB** before
reading it, and the sanitizer refuses markup over **512 KB** or **4096 elements**.

## Slots — the binding surface {#slots--the-binding-surface}

A slot declares one input: which element it drives, what it does to it, and how input values map
to output. The placed dashboard instance later chooses each slot's source (a Logic address or a
fixed value); the symbol itself never stores a source.

Each slot carries:

| Field | Meaning |
| --- | --- |
| `name` | The parameter name shown to the dashboard author. Identifier grammar above; unique across the symbol's slots. |
| `elementId` | The `id` of an existing SVG element. One element accepts at most one slot per channel, so two slots cannot fight over the same element's fill. |
| `kind` | One of the thirteen kinds below. |
| `inMin`, `inMax` | The input range: the two values mapped to `outMin`/`outMax`. Finite numbers; used by the continuous kinds and as the threshold midpoint by `fill`/`stroke`. |
| `outMin`, `outMax` | The output range in the kind's unit (degrees, view-box units, 0..1, degrees per second). Finite numbers. |
| `outMinSecondary`, `outMaxSecondary` | Optional second output axis, `translate` and `scale` only: the primary output is X and the secondary is Y. Supply both or neither. |
| `onColor`, `offColor` | Safe paints for `fill`/`stroke` (the two threshold states). For `fillColor`/`strokeColor`, `onColor` is the inspector's preview color. |

The thirteen slot kinds:

| Kind | What it drives | Notes |
| --- | --- | --- |
| `rotate` | Rotates the element; input maps linearly to degrees. | Target must declare `data-ganter-pivot`. |
| `opacity` | The element's opacity; input maps to 0..1. | |
| `fill` | Paints the fill `offColor` below the midpoint of the input range and `onColor` at or above it. A Bool input switches directly. | Targets fillable shapes (not `<line>`). |
| `visible` | Shows or hides the element by the value's truthiness. | |
| `text` | Replaces the element's text content with the formatted value. | Targets `<text>`/`<tspan>` only; combine with the `data-ganter-fit-*` attributes for a bounded readout. |
| `translate` | Moves the element in view-box units; primary output is X, optional secondary is Y. | |
| `scale` | Scales the element; primary output is X, secondary is Y (omitted = uniform). | Target must declare `data-ganter-pivot`. |
| `stroke` | Paints the stroke by the same threshold rule as `fill`. | |
| `spin` | Continuous rotation over time; input maps to angular velocity in degrees per second. Children with `data-ganter-spin-ratio` co-rotate at their declared ratio. | Target must declare `data-ganter-pivot`. Motion is host-scheduled and independent of operating-system animation preferences. |
| `spinEnabled` | Pauses or resumes the `spin` slot on the same element without hiding it. | Requires a `spin` slot targeting the same element. |
| `fillColor` | Sets the fill directly from the bound color value (a Color asset, Color variable or safe CSS color). | |
| `strokeColor` | Sets the stroke directly from the bound color value. | |
| `strokeWidth` | Sets the stroke width directly, in SVG user units from **0.5 through 32**; no unit suffix or CSS accepted. | Invalid, unresolved or bad-quality input restores the authored `stroke-width`; so do Reset, remount and disposal. |

These slots style artwork; they never style Dashboard connection routes, whose color and width are
global per connection type.

## Binding ownership {#binding-ownership}

The symbol declares parameters and permitted type choices, not live sources or an instance's
selection. After placing a Symbol component, the operator maps each slot to a Logic address or a
fixed value and picks each configurable connection type on that instance. Those choices stay inside
the station and are never exported with the reusable symbol. Unmapped slots stay neutral: the
element keeps its authored appearance.

## Global positions and connection types {#global-positions-and-connection-types}

Every terminal references one position from the fixed sixteen-anchor perimeter catalog. A package
stores only `anchorId`; it never repeats or overrides coordinates. The positions, as normalized
fractions of the view box:

- top: `top-left` (0, 0), `top-25` (0.25, 0), `top-50` (0.5, 0), `top-75` (0.75, 0),
  `top-right` (1, 0);
- right: `right-25` (1, 0.25), `right-50` (1, 0.5), `right-75` (1, 0.75);
- bottom, clockwise: `bottom-right` (1, 1), `bottom-75` (0.75, 1),
  `bottom-50` (0.5, 1), `bottom-25` (0.25, 1), `bottom-left` (0, 1);
- left, continuing clockwise: `left-75` (0, 0.75), `left-50` (0, 0.5),
  `left-25` (0, 0.25).

These are perimeter positions, not a 5×5 grid. A symbol cannot add another position or store its
own anchor coordinates.

The eight global connection types are `liquid`, `gas`, `air-duct`, `electrical`, `signal`,
`network`, `material` and `mechanical-shaft`. Use **Logic > Assets > Connection types** to
configure the color and visual stroke width associated with each type. Symbols and connections
retain the stable type id, so a global change restyles every route that uses it. A package never
copies the color or width and has no small, standard or large route variants.

Two endpoints form a defined connection when they resolve to the same type. The terminal role helps
authoring and review but does not prohibit a network arrangement by itself.

## Shared type choices {#shared-type-choices}

A symbol that can work with more than one medium declares a shared type configuration rather than
duplicating its artwork:

```json
{
  "id": "process-type",
  "name": "Process type",
  "allowedTypes": ["liquid", "gas"],
  "defaultType": "liquid"
}
```

The `id` is a stable key: lowercase letters and digits with single dashes, starting and ending
alphanumeric (uppercase is refused). `allowedTypes` is nonempty and contains unique global type
ids; `defaultType` must belong to it; `name` is required. Every terminal that references
`process-type` follows one choice made on the placed dashboard instance. A definition may carry
several independent configurations, such as process and vent media, while other terminals stay
fixed.

## Complete terminals {#complete-terminals}

Each connectable terminal declares one global position, a role, exactly one fixed type **or** one
shared configuration, an outward direction in the original orientation, and an internal arrival
path:

```json
{
  "anchorId": "left-50",
  "role": "input",
  "typeConfiguration": "process-type",
  "direction": "west",
  "internalPath": [
    { "x": 18, "y": 50 },
    { "x": 36, "y": 50 },
    { "x": 48, "y": 62 }
  ]
}
```

The rules, all enforced at import:

- At most **16** terminals, and each anchor id used at most once.
- `role` is `input`, `output` or `bidirectional`; `direction` is `north`, `east`, `south` or
  `west`.
- Exactly one of `type` (a global connection type) or `typeConfiguration` (a declared
  configuration id) — never both, never neither.
- The global anchor converted to the SVG's natural view box is the implicit start of
  `internalPath`; do not repeat it as the first point. Coordinates are absolute view-box values,
  not normalized fractions, and the path has at least one point.
- Every segment stays inside the view box, has nonzero length, and is horizontal, vertical or
  exactly 45°; consecutive segments turn by at most 90°.
- Terminals require the SVG to carry a view box at all.

The Dashboard draws the internal path below the equipment artwork, transforms it with resize,
quarter-turn rotation and flips, and keeps the global connection type's visual width instead of
scaling it with the symbol, so one continuous stroke runs from inside one body to inside the other.

A terminal is all-or-nothing. A strict package with a partial terminal is invalid; a visual-only
package uses an empty `terminals` array instead. Nothing is ever inferred to fill a gap.

## Ordered suggestions {#ordered-suggestions}

A suggestion records an intentional composition, including the exact target pose:

```json
{
  "sourceAnchorId": "right-75",
  "targetSymbol": "system:capping-station",
  "targetAnchorId": "left-75",
  "targetRotationDegrees": 0,
  "targetFlipHorizontal": false,
  "targetFlipVertical": false,
  "connectionType": "material"
}
```

Validation: `sourceAnchorId` must be one of this symbol's own terminals; `targetSymbol` must be a
source-qualified reference, and in a portable package it may only be `system:<stable-key>` (a user
GUID is meaningful only inside its owning station) naming a symbol that exists in the system
catalog with a terminal at `targetAnchorId`; rotation is `0`, `90`, `180` or `270` and both flip
flags are required; each suggestion must be unique. `connectionType` can be omitted only when the
equal fixed types at both endpoints resolve the scenario unambiguously; it is required when either
endpoint is configurable, and it must then be allowed at both ends.

Suggestions are directional and array order sets priority, but they are never an allow-list for
ordinary Dashboard connections. With an empty `suggestions` array the inspector reports that no
suggested composition exists instead of inventing a catalog partner.

## What the import refuses {#what-the-import-refuses}

A quick checklist of the refusals, so a failed import can be read instead of guessed at. For a
plain SVG, only the first group applies, and within it just the size caps, malformed XML and a
missing `<svg>` root refuse the import — disallowed markup is repaired with a warning instead. For
a package, everything below is a hard rejection.

**Artwork**

- A file over 1 MB, markup over 512 KB, or more than 4096 elements.
- Malformed XML, or a root that is not a sanitized `<svg>`.
- In a package: any markup the allow-list would have had to remove (scripts, event handlers,
  external references, disallowed elements or attributes).
- A `viewBox` that is not four finite numbers with positive width and height, or that differs from
  the SVG root's; a package without a root view box at all.

**Envelope**

- A schema, format or version other than the v2 contract; unknown fields anywhere; a missing
  `name`, `fit`, `footprint` or any of the three arrays; a null entry inside an array.
- A `fit` outside the four modes; a footprint outside 1–24 × 1–12.

**Slots**

- A name or element id outside the identifier grammar; a duplicate slot name; a target id the SVG
  does not contain; a kind the target element cannot take (text on a non-text element, fill on a
  `<line>`).
- A `rotate`, `spin` or `scale` slot whose target has no finite `data-ganter-pivot`; a
  `spinEnabled` slot without a `spin` slot on the same element.
- Two slots on one element's same channel. Every kind is its own channel except that `fill` and
  `fillColor` share the fill channel and `stroke` and `strokeColor` share the stroke channel, so an
  element cannot take two rotations any more than two fills.
- Non-finite range numbers; a secondary output with only one endpoint, or on a kind other than
  `translate`/`scale`; an unsafe paint; a slot that carries a Logic address.

**Connections**

- More than 16 terminals; a repeated or unknown anchor id; a terminal with both or neither of
  `type`/`typeConfiguration`; an unknown type or configuration id; an invalid internal path
  (outside the view box, zero-length or off-angle segments, a turn over 90°).
- A type configuration with an invalid id, a missing name, duplicate or unknown allowed types, or
  a default outside its own list.
- A suggestion whose source terminal does not exist, whose target is not a system symbol with the
  named terminal, whose pose is not a quarter turn, or whose connection type is not accepted at
  both ends.

`symbol_validate` on the [MCP endpoint](agent) runs exactly this import as a dry run and reports
every error, warning and note without persisting anything.

## Packaging and sharing {#packaging-and-sharing}

**Export package** (available for system and user symbols alike) downloads
`<name>.ganter-symbol.json` with the sanitized artwork and the full contract: `$schema`, `format`,
`version`, `name`, `svg`, `viewBox`, `fit`, `footprint`, `slots[]`, `typeConfigurations[]`,
`terminals[]` and `suggestions[]`, the three connection arrays present even when empty. What never
travels: station addresses, per-instance type choices, database identity, user identity, and the
station's connection-type colors and widths.

On the receiving station, the same file goes through **Import symbol** (or `symbol_import` over
MCP) and lands as a new user symbol, terminals and all. Because type identity is a stable id and
appearance is global, an imported symbol's routes immediately follow the receiving station's own
connection-type styling.
