Files
personal-wiki/skills/canvas/references/canvas-spec.md
Daniel 3b4f62eb0c feat: /canvas command — visual layer of the wiki
New files:
  commands/canvas.md              — slash command with full trigger table
  skills/canvas/SKILL.md          — complete skill: open, new, add image/text/pdf/note,
                                    zone, list, from-banana operations
  skills/canvas/references/canvas-spec.md — Obsidian canvas JSON spec:
                                    coordinate system, all node types, color table,
                                    image sizing by aspect ratio, auto-positioning
                                    pseudocode, common mistakes, full example

Auto-positioning algorithm (bbox math):
  - Finds rightmost node in target zone
  - Places next node at rightmost_x + 40, same row y
  - If overflow: wraps to new row (max_y_in_zone + 20)
  - Falls back to below all content if no zone found

Banana integration:
  - /canvas from banana: checks .recent-images.txt first, then
    filesystem find -newer 10min, presents list for confirmation
  - After any /banana run: suggests /canvas from banana

Updated:
  skills/wiki/SKILL.md        — added /canvas to routing table
  skills/wiki/references/plugins.md — added Calendar + Thino with
                                      pre-installed note + manual install fallback
  README.md                   — /canvas in commands table + file structure
2026-04-07 13:23:33 +03:00

6.8 KiB
Raw Blame History

Obsidian Canvas JSON Specification

Canvas files are JSON with two top-level keys: nodes (array) and edges (array). Obsidian reads and writes them as UTF-8 JSON files with .canvas extension.


Coordinate System

        x increases →
   ┌─────────────────────────────────
   │  (-920, -2400)      (0, -2400)
   │
y  │  (-920, 0)          (0, 0) ← origin
↓  │
   │  (-920, 540)        (500, 540)
  • Origin (0, 0) is the center of the canvas viewport.
  • x increases rightward. Negative x = left of center.
  • y increases downward. Negative y = above center.
  • Node x and y are the top-left corner of the node, not the center.
  • Obsidian pans to fit all nodes on first open.

Node Types

Text node

Renders markdown content as a styled card.

{
  "id": "text-title-4821",
  "type": "text",
  "text": "# Heading\n\nParagraph with **bold** and `code`.",
  "x": -400,
  "y": -300,
  "width": 400,
  "height": 120,
  "color": "6"
}
  • text: markdown string. Use \n for newlines.
  • Minimum readable size: width ≥ 200, height ≥ 60.
  • color is optional. Omit for default (no color).

File node

Renders an image, PDF, markdown note, or other vault file inline.

{
  "id": "img-cover-7823",
  "type": "file",
  "file": "_attachments/images/skool-hub/logo-pro.png",
  "x": -900,
  "y": -100,
  "width": 420,
  "height": 236
}
  • file: vault-relative path (not absolute, not ~/).
  • Supported: .png .jpg .webp .gif .pdf .md .canvas
  • For .md files: renders as a preview card.
  • For .pdf files: renders the first page as preview.
  • No color field for file nodes — color is ignored.

Group node (Zone)

A labeled rectangular region. Does not clip or contain nodes — it's a visual guide. Nodes placed "inside" a group are just positioned within its bounding box.

{
  "id": "zone-branding-3391",
  "type": "group",
  "label": "Brand Identity",
  "x": -920,
  "y": -880,
  "width": 1060,
  "height": 290,
  "color": "6"
}
  • label: shown at the top of the group box.
  • color: colors the group border and label.
  • Groups do not affect auto-layout — they are purely visual containers.

Renders a web URL as an embedded preview card.

{
  "id": "link-karpathy-2233",
  "type": "link",
  "url": "https://github.com/karpathy",
  "x": 200,
  "y": -300,
  "width": 400,
  "height": 120
}
  • url: must be a valid https:// URL.
  • Obsidian fetches the Open Graph preview (title, description, thumbnail).

Edges

Connections between nodes. Usually empty for mood boards.

{
  "id": "e-hub-cidx",
  "fromNode": "hub",
  "fromSide": "right",
  "toNode": "c-idx",
  "toSide": "left",
  "toEnd": "arrow",
  "label": "concepts",
  "color": "5"
}
  • fromSide / toSide: "top" "bottom" "left" "right"
  • toEnd: "arrow" (directed) or "none" (undirected line)
  • label and color are optional.

Color Reference

Code Color Hex (approx) Use case
"1" Red / Tomato #e03e3e Warnings, archive
"2" Orange #d09035 Active work
"3" Yellow / Gold #d0a023 WIP, notes
"4" Green / Teal #448361 Content, sources
"5" Blue / Cyan #3ea7d3 Navigation, info
"6" Purple / Violet #9063d2 Title, identity

Omit color entirely for the default (no border color, transparent label).


Image Sizing Guidelines

Calculate from actual image dimensions using PIL or identify:

python3 -c "from PIL import Image; img=Image.open('path.png'); print(img.width, img.height)"
# or
identify -format '%w %h' path.png
Aspect ratio Condition Canvas width Canvas height
16:9 (wide) ratio 1.62.0 420 236
2:1 (ultra wide) ratio > 2.0 440 220
4:3 ratio 1.21.6 380 285
1:1 (square) ratio 0.91.1 280 280
3:4 ratio 0.60.9 240 320
9:16 (portrait) ratio < 0.6 200 356
PDF any 400 520
Unknown fallback 320 240

Auto-Positioning Pseudocode

function place_node(canvas, zone_label, new_w, new_h):
  zone = find group node where label == zone_label
  padding = 20

  if zone not found:
    max_y = max(n.y + n.height for n in canvas.nodes) + 60
    return (-400, max_y)

  # Nodes visually inside zone
  inside = [n for n in canvas.nodes
            if n.type != 'group'
            and zone.x <= n.x < zone.x + zone.width
            and zone.y <= n.y < zone.y + zone.height]

  if inside is empty:
    return (zone.x + padding, zone.y + padding)

  # Rightmost point in zone
  rightmost = max(n.x + n.width for n in inside)
  next_x = rightmost + 40

  if next_x + new_w > zone.x + zone.width - padding:
    # Overflow → new row
    bottom_of_row = max(n.y + n.height for n in inside)
    return (zone.x + padding, bottom_of_row + padding)

  # Same row
  row_y = min(n.y for n in inside)  # align to top of existing row
  return (next_x, row_y)

Full Example: Two-Zone Canvas

{
  "nodes": [
    {
      "id": "title-0001",
      "type": "text",
      "text": "# Brand Reference\n\n**AI Marketing Hub** visual assets",
      "x": -920, "y": -2440, "width": 560, "height": 180, "color": "6"
    },
    {
      "id": "zone-logos",
      "type": "group",
      "label": "Logos & Icons",
      "x": -920, "y": -2200, "width": 1800, "height": 320, "color": "6"
    },
    {
      "id": "img-logo-pro",
      "type": "file",
      "file": "_attachments/images/skool-hub/logo-pro.png",
      "x": -900, "y": -2180, "width": 420, "height": 236
    },
    {
      "id": "img-icon-free",
      "type": "file",
      "file": "_attachments/images/skool-hub/icon-free-v1.png",
      "x": -440, "y": -2180, "width": 280, "height": 280
    },
    {
      "id": "zone-covers",
      "type": "group",
      "label": "Skill Covers",
      "x": -920, "y": -1820, "width": 1800, "height": 340, "color": "3"
    },
    {
      "id": "img-seo",
      "type": "file",
      "file": "_attachments/images/skool-hub/claude-seo-cover.png",
      "x": -900, "y": -1800, "width": 420, "height": 236
    }
  ],
  "edges": []
}

Common Mistakes

  • Wrong path format: use _attachments/images/file.png not /home/user/... or ~/...
  • ID collision: always read existing IDs before generating a new one
  • Negative y confusion: y: -2400 is ABOVE y: -1000 (more negative = higher up)
  • Group does not clip: placing a node "inside" a group is just positioning it within the group's bounding box — there is no parent-child relationship in the JSON
  • Missing height on text nodes: Obsidian will render the text but may clip it if height is too small. Use height ≥ content-lines × 24.