Evanfeenstra

System Canvas JSON Generation

7. The JSON is valid (no trailing commas, no comments).

Evanfeenstra 0 Updated 1mo ago

Resources

8
GitHub

Install

npx skillscat add evanfeenstra/system-canvas

Install via the SkillsCat registry.

SKILL.md

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.
  • x and y define 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 - padding

Where 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

  1. Plan rows first. Decide how many horizontal rows of nodes you need.
  2. Place nodes left-to-right within each row with consistent gaps.
  3. Place groups around their children after the children are positioned. Compute the group bounds from the children's extremes plus padding.
  4. Flow direction: generally left-to-right (clients -> gateway -> services -> databases). External/entry nodes on the left, persistence on the right or bottom.
  5. 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 ref for nodes that represent subsystems with internal structure worth exploring.
  • The ref value is an opaque string. Use a consistent naming scheme like canvas:api-gateway or canvas: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 text is 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 \n line 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

  1. Every id is unique across all nodes and all edges in the document.
  2. Every edge references node IDs that exist in the same document.
  3. Groups appear before their children in the nodes array.
  4. Every child node fits fully inside its parent group with at least 20px padding.
  5. No two nodes overlap.
  6. Colors are assigned by semantic role, not randomly.
  7. The JSON is valid (no trailing commas, no comments).