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:
273
skills/canvas/SKILL.md
Normal file
273
skills/canvas/SKILL.md
Normal file
@@ -0,0 +1,273 @@
|
||||
---
|
||||
name: canvas
|
||||
description: >
|
||||
Visual layer of the wiki. Add images, text cards, PDFs, and wiki pages to Obsidian
|
||||
canvas files. Auto-positions nodes inside zones using bbox math. Integrates with
|
||||
/banana skill for generated image capture. Triggers on: "/canvas", "canvas new",
|
||||
"canvas add image", "canvas add text", "canvas add pdf", "canvas add note",
|
||||
"canvas zone", "canvas list", "canvas from banana", "add to canvas",
|
||||
"put this on the canvas", "open canvas", "create canvas".
|
||||
allowed-tools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]
|
||||
---
|
||||
|
||||
# canvas — Visual Reference Layer
|
||||
|
||||
The three knowledge capture layers:
|
||||
- `/save` → text synthesis (wiki/questions/, wiki/concepts/)
|
||||
- `/autoresearch` → structured knowledge (wiki/sources/, wiki/concepts/)
|
||||
- `/canvas` → visual references (wiki/canvases/)
|
||||
|
||||
A canvas is a JSON file Obsidian renders as an infinite visual board. This skill reads and writes canvas JSON directly. Read `references/canvas-spec.md` for the full format reference before making any edits.
|
||||
|
||||
---
|
||||
|
||||
## Default Canvas
|
||||
|
||||
`wiki/canvases/main.canvas`
|
||||
|
||||
If it does not exist, create it:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "title",
|
||||
"type": "text",
|
||||
"text": "# Visual Reference\n\nDrop images, PDFs, and notes here.",
|
||||
"x": -400, "y": -300, "width": 400, "height": 120, "color": "6"
|
||||
},
|
||||
{
|
||||
"id": "zone-default",
|
||||
"type": "group",
|
||||
"label": "General",
|
||||
"x": -400, "y": -140, "width": 800, "height": 400, "color": "4"
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
### open / status (`/canvas` with no args)
|
||||
|
||||
1. Check if `wiki/canvases/main.canvas` exists.
|
||||
2. If yes: read it, count nodes by type, list all group node labels (zone names).
|
||||
Report: "Canvas has N nodes: X images, Y text cards, Z wiki pages. Zones: [list]"
|
||||
3. If no: create it with the starter structure above.
|
||||
Report: "Created main.canvas with a General zone."
|
||||
4. Tell user: "Open `wiki/canvases/main.canvas` in Obsidian to view."
|
||||
|
||||
---
|
||||
|
||||
### new (`/canvas new [name]`)
|
||||
|
||||
1. Slugify the name: lowercase, spaces → hyphens, strip special chars.
|
||||
2. Create `wiki/canvases/[slug].canvas` with the starter structure, title updated to `# [Name]`.
|
||||
3. Add entry to `wiki/index.md` under a "## Canvases" section (create the section if it doesn't exist).
|
||||
4. Report: "Created wiki/canvases/[slug].canvas"
|
||||
|
||||
---
|
||||
|
||||
### add image (`/canvas add image [path or url]`)
|
||||
|
||||
**Resolve the image:**
|
||||
- If URL (starts with `http`): download with `curl -sL [url] -o _attachments/images/canvas/[filename]`
|
||||
Derive filename from URL path, or use `img-[timestamp].jpg` if unclear.
|
||||
- If local path outside vault: `cp [path] _attachments/images/canvas/`
|
||||
- If already vault-relative: use as-is.
|
||||
|
||||
Create `_attachments/images/canvas/` if it doesn't exist.
|
||||
|
||||
**Detect aspect ratio:**
|
||||
Use `python3 -c "from PIL import Image; img=Image.open('[path]'); print(img.width, img.height)"` or `identify -format '%w %h' [path]`.
|
||||
- 16:9 (width/height ≈ 1.7–2.0): width=420, height=236
|
||||
- 1:1 (ratio ≈ 1.0): width=280, height=280
|
||||
- 9:16 (ratio < 0.65): width=200, height=356
|
||||
- Unknown: width=320, height=240
|
||||
|
||||
**Position using auto-layout** (see Auto-Positioning section below).
|
||||
|
||||
**Append node to canvas JSON and write.**
|
||||
|
||||
Report: "Added [filename] to [zone] zone at position ([x], [y])."
|
||||
|
||||
---
|
||||
|
||||
### add text (`/canvas add text [content]`)
|
||||
|
||||
Create a text node:
|
||||
```json
|
||||
{
|
||||
"id": "text-[timestamp]",
|
||||
"type": "text",
|
||||
"text": "[content]",
|
||||
"x": [auto], "y": [auto],
|
||||
"width": 300, "height": 120,
|
||||
"color": "4"
|
||||
}
|
||||
```
|
||||
|
||||
Position using auto-layout. Write and report.
|
||||
|
||||
---
|
||||
|
||||
### add pdf (`/canvas add pdf [path]`)
|
||||
|
||||
Same as add image. Obsidian renders PDFs natively as file nodes.
|
||||
- Copy to `_attachments/pdfs/canvas/` if outside vault.
|
||||
- Fixed size: width=400, height=520.
|
||||
- Report page count if you can determine it.
|
||||
|
||||
---
|
||||
|
||||
### add note (`/canvas add note [wiki-page]`)
|
||||
|
||||
1. Search `wiki/` for a file matching the page name (case-insensitive, partial match ok).
|
||||
2. Use the vault-relative path as the `file` field.
|
||||
3. Create a file node: width=300, height=100.
|
||||
4. Position using auto-layout.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "note-[timestamp]",
|
||||
"type": "file",
|
||||
"file": "wiki/concepts/LLM Wiki Pattern.md",
|
||||
"x": [auto], "y": [auto],
|
||||
"width": 300, "height": 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### zone (`/canvas zone [name] [color]`)
|
||||
|
||||
1. Read canvas JSON.
|
||||
2. Find max_y: `max(node.y + node.height for all nodes) + 60`. Use -140 if no nodes.
|
||||
3. Create a group node:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "zone-[slug]",
|
||||
"type": "group",
|
||||
"label": "[name]",
|
||||
"x": -400,
|
||||
"y": [max_y],
|
||||
"width": 1000,
|
||||
"height": 400,
|
||||
"color": "[color or '3']"
|
||||
}
|
||||
```
|
||||
|
||||
Valid colors: `"1"`=red `"2"`=orange `"3"`=yellow `"4"`=green `"5"`=cyan `"6"`=purple
|
||||
|
||||
Write and report.
|
||||
|
||||
---
|
||||
|
||||
### list (`/canvas list`)
|
||||
|
||||
1. `glob wiki/canvases/*.canvas`
|
||||
2. For each canvas: read JSON, count nodes by type.
|
||||
3. Report:
|
||||
|
||||
```
|
||||
wiki/canvases/main.canvas — 14 nodes (8 images, 3 text, 2 file, 1 group)
|
||||
wiki/canvases/design-ideas.canvas — 42 nodes (30 images, 4 text, 8 groups)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### from banana (`/canvas from banana`)
|
||||
|
||||
1. Check `wiki/canvases/.recent-images.txt` first (session log of newly written images).
|
||||
2. If not found or empty: `find _attachments/images -name "*.png" -o -name "*.jpg" -newer [10min-ago-file]`
|
||||
Create the reference file: `python3 -c "import time; open('/tmp/ten-min-ago','w').close(); import os; os.utime('/tmp/ten-min-ago', (time.time()-600, time.time()-600))"`
|
||||
Then: `find _attachments/images -newer /tmp/ten-min-ago -name "*.png" -o -name "*.jpg"`
|
||||
3. If still none: show the 5 most recently modified images.
|
||||
4. Present list: "Found N recent images: [list]. Add to canvas? Which zone? (zone name / 'new [name]' / 'skip')"
|
||||
5. On confirmation: add each using the add image logic.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Positioning Algorithm
|
||||
|
||||
Read `references/canvas-spec.md` for the full coordinate system.
|
||||
|
||||
```python
|
||||
def next_position(canvas_nodes, target_zone_label, new_w, new_h):
|
||||
# Find zone group node
|
||||
zone = next((n for n in canvas_nodes
|
||||
if n.get('type') == 'group'
|
||||
and n.get('label') == target_zone_label), None)
|
||||
|
||||
if zone is None:
|
||||
# No zone — place below all content
|
||||
max_y = max((n['y'] + n.get('height', 0) for n in canvas_nodes), default=-140)
|
||||
return -400, max_y + 60
|
||||
|
||||
zx, zy = zone['x'], zone['y']
|
||||
zw, zh = zone['width'], zone['height']
|
||||
|
||||
# Nodes inside this zone
|
||||
inside = [n for n in canvas_nodes
|
||||
if n.get('type') != 'group'
|
||||
and zx <= n['x'] < zx + zw
|
||||
and zy <= n['y'] < zy + zh]
|
||||
|
||||
if not inside:
|
||||
return zx + 20, zy + 20
|
||||
|
||||
rightmost_x = max(n['x'] + n.get('width', 0) for n in inside)
|
||||
next_x = rightmost_x + 40
|
||||
|
||||
if next_x + new_w > zx + zw:
|
||||
# New row
|
||||
max_row_y = max(n['y'] + n.get('height', 0) for n in inside)
|
||||
return zx + 20, max_row_y + 20
|
||||
|
||||
# Same row — match the top y of existing row
|
||||
current_row_y = min(n['y'] for n in inside if n['x'] > zx + zw / 2) \
|
||||
if any(n['x'] > zx + zw / 2 for n in inside) \
|
||||
else zy + 20
|
||||
return next_x, current_row_y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ID Generation
|
||||
|
||||
Read the canvas, collect all existing IDs. Never reuse one.
|
||||
|
||||
Safe ID pattern: `[type]-[content-slug]-[unix-timestamp-last-4-digits]`
|
||||
|
||||
Examples: `img-cover-7823`, `text-note-1045`, `zone-branding-3391`
|
||||
|
||||
---
|
||||
|
||||
## Session Log (optional hook)
|
||||
|
||||
If `wiki/canvases/.recent-images.txt` exists, append any new image path written to `_attachments/images/` during this session (one path per line, keep last 20).
|
||||
|
||||
`/canvas from banana` reads this file first, making it instant without filesystem search.
|
||||
|
||||
---
|
||||
|
||||
## Banana Integration
|
||||
|
||||
After any `/banana` run in the same session, if the user says "add to canvas" or "put on canvas", treat it as `/canvas from banana`.
|
||||
|
||||
When `/banana` finishes generating images, suggest:
|
||||
> "Add generated images to canvas? Run `/canvas from banana`"
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
1. Read canvas-spec.md before editing any canvas JSON.
|
||||
2. Always read the canvas file before writing — parse existing nodes to avoid ID collisions and calculate auto-positions.
|
||||
3. Create `_attachments/images/canvas/` for downloaded/copied images.
|
||||
4. Update `wiki/index.md` when creating new canvases.
|
||||
5. Report position and zone after every add operation.
|
||||
Reference in New Issue
Block a user