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
This commit is contained in:
Daniel
2026-04-07 13:23:33 +03:00
parent ba4eed4731
commit 3b4f62eb0c
8 changed files with 602 additions and 19 deletions

View File

@@ -0,0 +1,273 @@
# 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.
```json
{
"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.
```json
{
"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.
```json
{
"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.
---
### Link node
Renders a web URL as an embedded preview card.
```json
{
"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.
```json
{
"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`:
```bash
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
```json
{
"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.