Resources
8Install
npx skillscat add evanfeenstra/system-canvas Install via the SkillsCat registry.
System Canvas JSON Generation
You generate JSON Canvas documents for the system-canvas library. Your output is rendered as interactive, zoomable SVG diagrams. Follow these rules precisely.
Output format
Return a single JSON object matching this schema:
{
"theme": {
"base": "dark",
"categories": { ... }
},
"nodes": [...],
"edges": [...]
}All three top-level keys are optional. Return valid JSON only -- no comments, no trailing commas.
Theme
The theme field lets you declare a base theme and define categories inline so the canvas is fully self-describing.
{
"theme": {
"base": "dark",
"categories": {
"service": {
"defaultWidth": 140,
"defaultHeight": 60,
"fill": "rgba(6, 78, 59, 0.4)",
"stroke": "#34d399",
"cornerRadius": 6,
"icon": "server"
},
"database": {
"defaultWidth": 140,
"defaultHeight": 60,
"fill": "rgba(76, 29, 149, 0.4)",
"stroke": "#a78bfa",
"cornerRadius": 6,
"icon": "database"
}
}
}
}base
Name of a built-in theme. Options: "dark", "midnight", "light", "blueprint", "warm". Default: "dark".
Choose based on context:
"dark"— general purpose, good default"midnight"— neon accents, high contrast, terminal aesthetic"light"— professional/documentation contexts"blueprint"— technical/architectural contexts"warm"— design studio, less "tech" feel
categories
A map of category name to visual definition. When a node has "category": "service", its dimensions and colors come from the matching category definition.
| Field | Type | Required | Description |
|---|---|---|---|
defaultWidth |
number | Yes | Width in pixels when node omits width. |
defaultHeight |
number | Yes | Height in pixels when node omits height. |
fill |
string | Yes | CSS fill color. Use rgba() with low alpha (0.3-0.5) for the translucent glass effect. |
stroke |
string | Yes | CSS stroke/border color. Use a solid bright color that contrasts with the fill. |
cornerRadius |
number | No | Border radius in pixels. Default: 6. Use 40+ for circular nodes. |
icon |
string | No | Icon identifier rendered inside the node. Available: "server", "database", "person", "cloud", "lock", "globe", "code", "folder". |
Fill/stroke pairing guide (for dark themes):
| Role | Fill | Stroke | Notes |
|---|---|---|---|
| Backend/service | rgba(6, 78, 59, 0.4) |
#34d399 |
Emerald family |
| Database/storage | rgba(76, 29, 149, 0.4) |
#a78bfa |
Violet family |
| Frontend/client | rgba(8, 51, 68, 0.4) |
#22d3ee |
Cyan family |
| Security/auth | rgba(136, 19, 55, 0.4) |
#fb7185 |
Rose family |
| Messaging/event | rgba(120, 53, 15, 0.3) |
#fb923c |
Orange family |
| Infrastructure | rgba(120, 53, 15, 0.3) |
#fbbf24 |
Amber family |
| Person/team | rgba(30, 58, 138, 0.4) |
#60a5fa |
Blue family |
| Neutral/default | rgba(30, 41, 59, 0.5) |
#94a3b8 |
Slate family |
The pattern: fill is a dark, low-alpha version of the stroke color. This creates the glowing glass-panel effect.
When to define categories
Define categories when the diagram has repeated node types (e.g., multiple services, multiple databases). This lets nodes omit width, height, and color, keeping the JSON shorter and more consistent.
For simple diagrams with few repeated types, skip categories and use explicit width, height, and color on each node.
Node schema
{
"id": "unique-string",
"type": "text",
"x": 0,
"y": 0,
"width": 140,
"height": 60,
"color": "4",
"text": "Primary Label\nSublabel",
"ref": "canvas:sub-canvas-id",
"category": "service"
}Required fields
| Field | Type | Description |
|---|---|---|
id |
string | Unique identifier. Use lowercase kebab-case (e.g., api-gateway, postgres-primary). |
type |
string | One of: text, file, link, group. Use text for most nodes. |
x |
integer | X position in pixels. |
y |
integer | Y position in pixels. |
Optional fields
| Field | Type | Description |
|---|---|---|
width |
integer | Width in pixels. Default: 120. Required if no category. |
height |
integer | Height in pixels. Default: 60. Required if no category. |
color |
string | Preset "1"-"6" or hex "#FF0000". See color table below. |
text |
string | For type: "text". Use \n to separate the primary label from a sublabel. |
label |
string | For type: "group". The group's title. |
url |
string | For type: "link". The URL to display. |
file |
string | For type: "file". The file path to display. |
ref |
string | A URI that points to a sub-canvas. Nodes with ref become clickable and navigable. |
category |
string | Maps to a category definition in the theme. When set, width and height are optional. |
Preset colors
| Value | Color | Use for |
|---|---|---|
"1" |
Rose | Security, auth, sensitive systems |
"2" |
Orange | Messaging, events, queues, caches |
"3" |
Amber | Cloud infrastructure, CI/CD, ops |
"4" |
Emerald | Backend services, APIs, core logic |
"5" |
Cyan | Frontend, clients, ingress, networking |
"6" |
Violet | Databases, storage, persistence |
No color = neutral gray. Assign colors by semantic role, not arbitrarily.
Edge schema
{
"id": "edge-unique-string",
"fromNode": "source-node-id",
"toNode": "target-node-id",
"fromSide": "right",
"toSide": "left",
"label": "HTTPS",
"color": "5"
}Required fields
| Field | Type | Description |
|---|---|---|
id |
string | Unique edge identifier. |
fromNode |
string | id of the source node. Must exist in nodes. |
toNode |
string | id of the target node. Must exist in nodes. |
Optional fields
| Field | Type | Description |
|---|---|---|
fromSide |
string | top, right, bottom, or left. Auto-inferred if omitted. |
toSide |
string | top, right, bottom, or left. Auto-inferred if omitted. |
fromEnd |
string | none or arrow. Default: none. |
toEnd |
string | none or arrow. Default: arrow (shows arrowhead at target). |
label |
string | Short label displayed at the edge midpoint. Keep under 15 characters. |
color |
string | Same as node colors. Omit to use the default edge color. |
Group nodes
Groups are visual containers. They are type: "group" and use label instead of text.
A node is considered "inside" a group if it fits spatially within the group's bounding box. There is no explicit parent-child property -- containment is purely geometric.
Groups must appear before their children in the nodes array (they render behind other nodes).
{
"id": "infra-group",
"type": "group",
"x": 600,
"y": -30,
"width": 210,
"height": 400,
"label": "Infrastructure",
"color": "3"
}Layout rules
These rules are critical. Violations produce ugly, overlapping diagrams.
Coordinate system
- The canvas is an infinite 2D plane. Coordinates can be negative.
xandydefine the top-left corner of a node.- A node occupies the rectangle from
(x, y)to(x + width, y + height).
Spacing
- Horizontal gap between nodes: minimum 40px, prefer 60px.
- Vertical gap between rows: minimum 40px, prefer 40-60px.
- Padding inside groups: minimum 20px on all sides between the group boundary and its children. 30px is better.
- Gap between groups: minimum 40px.
Group containment math
Every child node must satisfy all four of these conditions:
child.x >= group.x + padding
child.y >= group.y + padding
child.x + child.width <= group.x + group.width - padding
child.y + child.height <= group.y + group.height - paddingWhere padding is at least 20px. Always verify this math before finalizing. If a child node violates any condition, either move the child inward or expand the group.
Layout strategy
- Plan rows first. Decide how many horizontal rows of nodes you need.
- Place nodes left-to-right within each row with consistent gaps.
- Place groups around their children after the children are positioned. Compute the group bounds from the children's extremes plus padding.
- Flow direction: generally left-to-right (clients -> gateway -> services -> databases). External/entry nodes on the left, persistence on the right or bottom.
- Align vertically when nodes in the same row serve the same tier (e.g., all databases at the same y).
Standard node sizes
| Type | Width | Height | Notes |
|---|---|---|---|
| Standard service | 140 | 60 | Most nodes |
| Wide service | 170 | 60 | Longer labels like "Prometheus + Grafana" |
| Thin bus/queue | 160 | 30 | Message buses, event streams |
| Small utility | 110 | 50 | Pods, workers, small components |
Avoiding overlaps
- Never place two nodes at the same
(x, y). - Before emitting the final JSON, mentally trace each node's bounding box and confirm no overlaps.
- Edges route automatically. You do not need to worry about edge paths crossing nodes, but placing nodes in a logical flow reduces visual clutter.
Sub-canvases and navigation
Nodes with a ref property become clickable. When clicked, the renderer navigates to the referenced sub-canvas (a separate CanvasData document).
- Use
reffor nodes that represent subsystems with internal structure worth exploring. - The
refvalue is an opaque string. Use a consistent naming scheme likecanvas:api-gatewayorcanvas:k8s-cluster. - When generating a system with sub-canvases, generate each sub-canvas as a separate JSON object. The top-level canvas and each sub-canvas are independent documents.
Text formatting
- First line of
textis the primary label (rendered bold, white). - Second line (after
\n) is the sublabel (rendered smaller, gray). - Keep primary labels under 20 characters.
- Keep sublabels under 25 characters.
- Do not use markdown. Just plain text with
\nline breaks.
Complete example
A system with a root canvas and one sub-canvas:
Root canvas
{
"theme": {
"base": "dark",
"categories": {
"service": {
"defaultWidth": 140,
"defaultHeight": 60,
"fill": "rgba(6, 78, 59, 0.4)",
"stroke": "#34d399",
"cornerRadius": 6,
"icon": "server"
},
"database": {
"defaultWidth": 140,
"defaultHeight": 60,
"fill": "rgba(76, 29, 149, 0.4)",
"stroke": "#a78bfa",
"cornerRadius": 6,
"icon": "database"
},
"frontend": {
"defaultWidth": 140,
"defaultHeight": 60,
"fill": "rgba(8, 51, 68, 0.4)",
"stroke": "#22d3ee",
"cornerRadius": 6,
"icon": "globe"
}
}
},
"nodes": [
{
"id": "eng-group",
"type": "group",
"x": -30,
"y": -30,
"width": 590,
"height": 330,
"label": "Engineering",
"color": "5"
},
{
"id": "api-gateway",
"type": "text",
"text": "API Gateway\nNginx + Kong",
"x": 0,
"y": 20,
"color": "4",
"category": "service",
"ref": "canvas:api-gateway"
},
{
"id": "auth-service",
"type": "text",
"text": "Auth Service\nOAuth2 / JWT",
"x": 200,
"y": 20,
"color": "1",
"category": "service"
},
{
"id": "user-service",
"type": "text",
"text": "User Service\nRust / Axum",
"x": 400,
"y": 20,
"color": "4",
"category": "service",
"ref": "canvas:user-service"
},
{
"id": "postgres",
"type": "text",
"text": "PostgreSQL\nPrimary",
"x": 80,
"y": 120,
"category": "database"
},
{
"id": "redis",
"type": "text",
"text": "Redis\nCache + Sessions",
"x": 300,
"y": 120,
"color": "2",
"category": "database"
},
{
"id": "kafka",
"type": "text",
"text": "Kafka",
"x": 180,
"y": 220,
"width": 160,
"height": 30,
"color": "2"
},
{
"id": "clients",
"type": "text",
"text": "Clients\nWeb + Mobile",
"x": -230,
"y": 20,
"category": "frontend"
}
],
"edges": [
{
"id": "e1",
"fromNode": "clients",
"fromSide": "right",
"toNode": "api-gateway",
"toSide": "left",
"label": "HTTPS"
},
{
"id": "e2",
"fromNode": "api-gateway",
"fromSide": "right",
"toNode": "auth-service",
"toSide": "left",
"label": "Auth"
},
{
"id": "e3",
"fromNode": "api-gateway",
"toNode": "user-service",
"label": "gRPC"
},
{
"id": "e4",
"fromNode": "auth-service",
"fromSide": "bottom",
"toNode": "redis",
"toSide": "top",
"label": "Sessions"
},
{
"id": "e5",
"fromNode": "user-service",
"fromSide": "bottom",
"toNode": "postgres",
"toSide": "right"
},
{
"id": "e6",
"fromNode": "postgres",
"fromSide": "bottom",
"toNode": "kafka",
"toSide": "left",
"label": "CDC"
}
]
}Sub-canvas: canvas:api-gateway
{
"nodes": [
{
"id": "group-gw",
"type": "group",
"x": -20,
"y": -20,
"width": 510,
"height": 230,
"label": "API Gateway Internals",
"color": "4"
},
{
"id": "nginx",
"type": "text",
"text": "Nginx\nReverse Proxy",
"x": 0,
"y": 30,
"width": 130,
"height": 55,
"color": "4"
},
{
"id": "kong",
"type": "text",
"text": "Kong\nAPI Management",
"x": 180,
"y": 30,
"width": 130,
"height": 55,
"color": "4"
},
{
"id": "rate-limiter",
"type": "text",
"text": "Rate Limiter\nRedis-backed",
"x": 0,
"y": 130,
"width": 130,
"height": 55,
"color": "1"
},
{
"id": "lb",
"type": "text",
"text": "Load Balancer\nRound Robin",
"x": 360,
"y": 30,
"width": 130,
"height": 55,
"color": "5"
}
],
"edges": [
{ "id": "gw-e1", "fromNode": "nginx", "toNode": "kong" },
{ "id": "gw-e2", "fromNode": "kong", "toNode": "lb" },
{
"id": "gw-e3",
"fromNode": "kong",
"fromSide": "bottom",
"toNode": "rate-limiter",
"toSide": "top"
}
]
}Checklist before emitting JSON
- Every
idis unique across all nodes and all edges in the document. - Every edge references node IDs that exist in the same document.
- Groups appear before their children in the
nodesarray. - Every child node fits fully inside its parent group with at least 20px padding.
- No two nodes overlap.
- Colors are assigned by semantic role, not randomly.
- The JSON is valid (no trailing commas, no comments).