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

273
skills/canvas/SKILL.md Normal file
View 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.72.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.

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.

View File

@@ -107,6 +107,7 @@ Route to the correct operation based on what the user says:
| "lint", "health check", "clean up" | LINT | `wiki-lint` |
| "save this", "file this", "/save" | SAVE | `save` |
| "/autoresearch [topic]", "research [topic]" | AUTORESEARCH | `autoresearch` |
| "/canvas", "add to canvas", "open canvas" | CANVAS | `canvas` |
---

View File

@@ -50,9 +50,15 @@ Install via Settings > Community Plugins > Turn off Restricted Mode > Browse.
| **Dataview** | Query vault as a database. Powers dashboards in `wiki/meta/`. |
| **Templater** | Auto-populate frontmatter on note creation from `_templates/`. |
| **Obsidian Git** | Auto-commit every 15 minutes. Protects against bad writes. |
| **Calendar** | Right-sidebar calendar with word count, task, and link indicators. Pre-installed in this vault via `.obsidian/plugins/calendar/`. |
| **Thino** | Quick memo capture panel in right sidebar. Pre-installed via `.obsidian/plugins/thino/`. |
| **Iconize** | Visual folder icons for navigation. |
| **Minimal Theme** | Best dark theme for dense information display. |
**Calendar and Thino are pre-installed** — they ship with this vault. Enable them in Settings → Community Plugins → toggle on. No download needed.
If installing in a different vault: download `main.js` + `manifest.json` from their GitHub releases into `.obsidian/plugins/calendar/` and `.obsidian/plugins/thino/` respectively.
Optional additions:
- **Smart Connections** — semantic search across all notes
- **QuickAdd** — macros for fast note creation