Canvases
Vita's native infinite canvas. A canvas project owns an ordered set of canvas tabs; each tab is a flat document of nodes (images, text, arrows, frames, and live measure cards) rendered by a from-scratch React canvas engine. Measures are project-scoped Python compute() scripts whose return value is rendered as a live data card. A single tab can be shared read-only via a per-canvas public token.
This page documents the engine and the authoring model. The canvases feature is content-agnostic β a measure runs arbitrary Python, so a canvas can hold any analysis, mockup board, or diagram you want.
Status: active (new, MayβJun 2026). Web UI at
/canvases; public shares athttps://vita-reports.ham.xyz/cv/{token}.
The three-level data model
project (proj_xxxxxxxx)
βββ canvases (canv_xxxxxxxx) β ordered tabs; each has one v2 doc
β βββ doc {format, version, nodes:[...]} β the drawing
βββ measures (m_xxxxxxxx) β project-scoped python compute() scripts
β βββ meta / script / latest / history
βββ assets (asset:β¦) β server-backed image/video blobs
| Level | ID prefix | What it is |
|---|---|---|
| Project | proj_ |
A namespace for canvases, measures, and assets. Created/renamed/archived. |
| Canvas (tab) | canv_ |
An ordered tab within a project. Holds one canvas document. |
| Measure | m_ |
A Python script rendered as a live value card; shared across the project's canvases. |
| Asset | asset:β¦ |
An uploaded image/video blob (server-stored, addressed by id). |
IDs are minted as <prefix>_<8 hex> (generate_uuid()[:8]). Every id arriving from a URL path param is validated against ^[a-z]+_[A-Za-z0-9]+$ before any filesystem join (_validate_id in scripts/canvases/core.py), so a crafted id like ../../etc cannot escape the data root. This is the core path-traversal guard for the whole feature.
Where state lives
All canvas state is on disk under data/canvases/. There is no database.
| Path | Contents | Write pattern |
|---|---|---|
data/canvases/projects.jsonl |
Project snapshots | JSONL, last-wins by id (atomic append) |
data/canvases/<pid>/canvases.jsonl |
Canvas (tab) metadata | JSONL, last-wins by id |
data/canvases/<pid>/canvases/<cid>.plat.json |
The canvas document (plat format) | Full JSON, atomic write (pre-2026-07-06 name .v2.json still readable as fallback) |
data/canvases/<pid>/measures/<mid>/meta.json |
Measure name/description/params | Atomic write (web UI owns it) |
data/canvases/<pid>/measures/<mid>/script.py |
The compute() script |
Atomic write (you author this) |
data/canvases/<pid>/measures/<mid>/latest.json |
Last run result + timestamp | Atomic write (runner only) |
data/canvases/<pid>/measures/<mid>/history.jsonl |
Run history | Append-only (runner only) |
data/canvases/<pid>/assets/<safe-id> |
Asset blob | Temp file + os.replace |
data/canvases/<pid>/assets/<safe-id>.meta.json |
Asset content-type + name | Atomic write |
The JSONL last-wins pattern (shared with other vita stores) means updates are appends, not rewrites: each line is a full snapshot keyed by id, and the reader keeps the last line per id (_load_jsonl_last_wins). Deletes are soft (deleted: true / archived: true flags), so history is preserved.
Note: projects/canvases use append-only JSONL; canvas docs, measure files, and assets are full files written atomically (
atomic_write,atomic_write_text, or temp +os.replace). See data integrity.
The canvas engine (embeddable library)
The drawing surface is a from-scratch infinite-canvas React library β plat (sibling checkout ~/work/plat, consumed as source via vite alias) with a hard host boundary: it never fetches or stores anything itself β the embedding application supplies a PlatHost adapter (doc load/save, asset uploads, measure data, share links). Vita is the first host: web/src/features/canvases/ holds vita's host layer (adapters, pages, measures panel, Amplitude measure-creation modal), and the route /canvases/{projectId} mounts <Plat> with the vita adapter. See the library docs: README.md, ARCHITECTURE.md, FORMAT.md, EMBEDDING.md in the plat repo.
Editor capabilities: paste images, draw arrows, add text/measure/link nodes, drag/resize, pan/zoom, undo/redo, snapping, multi-select, grouping, frames, and auto-layout.
Owner read-only mode
The lock switch in the project header puts the active canvas into a local read-only mode without remounting it, so the camera position is preserved. Editing controls disappear and store-level mutation guards activate, while pan, zoom, and links continue to work. Locking a canvas also clears the current selection and abandons any in-progress text edit.
This is a guard against accidental edits, not an access-control boundary.
The preference is stored per canvas in browser localStorage under
vita-canvas-readonly:<canvas_id>; it is neither written to the canvas document
nor synchronized to other browsers. Public token shares remain independently
read-only at the API boundary.
Document format (plat v1)
A canvas document is a flat list of nodes β there is no nested scene graph on disk.
{
"format": "plat",
"version": 1,
"nodes": [ /* AnyNode[] */ ]
}
Authoritative spec: ~/work/plat/FORMAT.md (which tracks plat's types.ts). Key invariants:
- Z-order = array order. A node's paint order (back β front) is its index in the
nodesarray. There is no separate z-index field. - All coordinates are absolute page-space. No parent-relative transforms; a frame subtracts its origin only at render time.
- The whole doc is PUT on every change β the editor autosaves the full
nodesarray; there are no partial writes.
Node types
Every non-arrow node shares a BoxBase (id, type, x, y, w, h, index, optional parentId/layoutIgnore).
type |
Purpose | Distinctive fields |
|---|---|---|
image |
A picture; src stretched to wΓh |
src, assetId?, name? |
text |
Clipped text box (no auto-grow) | text, fontSize, color?, align? |
measure |
Renders a measure's latest value | measureId (m_β¦), fontScale? |
link |
Clickable pill, opens in new tab | url, label? |
group |
Invisible container; bounds = union of members | members reference via parentId |
frame |
Titled, bordered, clipping container | name, optional layout block |
arrow |
Connector with optional bound endpoints | start/end (each with optional binding.nodeId) |
A measure node is just a pointer (measureId); the value it shows comes from that measure's latest.json. An arrow whose endpoint has binding.nodeId set follows that node β to keep an arrow attached when you swap a card for an image, reuse the original node's id.
Auto-layout
A frame with a layout object becomes a flex-style auto-layout container. Its flow children (box members with parentId === frame.id, layoutIgnore falsy, sorted by the fractional index string) are positioned automatically; the frame can hug (size to content) or stay fixed.
"layout": {
"dir": "h" | "v",
"gap": 16,
"padding": { "t":24, "r":24, "b":24, "l":24 },
"justify": "start" | "center" | "end" | "space-between",
"align": "start" | "center" | "end",
"sizing": { "main": "hug" | "fixed", "cross": "hug" | "fixed" }
}
Warning β no reflow-on-load (by design). Opening a canvas never moves anything. A script that writes a layout frame must also settle the child coordinates, or the on-disk positions won't match the declared layout. Two supported workflows:
- Write the frame + children with ascending
indexvalues, then run the reflow helper (a Python port oflayout.ts) to rewrite every child'sx/yand the frame's hugw/h:Idempotent. (PYTHONPATH=scripts uv run python -m canvases.reflow <project_id> <canvas_id>scripts/canvases/reflow.py; auto-layout math lives inscripts/canvases/autolayout.py/~/work/plat/src/layout.ts.)- Compute the final positions yourself and write settled
x/y/w/h(keep thelayoutblock so the editor maintains it on later edits).
Measures
A measure is a project-scoped Python script with one required entry point:
def compute() -> dict:
return {"value": 0.25, "label": "TODO"}
The runner (scripts/canvases/run_measure.py) imports script.py as a fresh module each run (so edits are picked up), calls compute(), validates that it returned a JSON-serializable dict, then writes latest.json (atomic) and appends to history.jsonl.
Result contract
The returned dict drives the measure card's two render modes:
| Key | Meaning |
|---|---|
value |
Primary number, rendered large (number mode). May be a pre-formatted string ("0.25%", "$78k") for exact control. |
label |
Short caption beneath the value. |
note |
Small justification line under the big number. |
link |
{"url","label"} β clickable "label β" to the source; always clickable, even in the editor. |
n, delta_pct |
Optional sample size / delta vs prior period. |
display |
Set to "text" to switch to text mode (no hero number). |
text |
Multi-line body rendered in text mode (\n preserved, scrollable). |
There are two render modes: number mode (default β one big value, a one-line caption, an optional note and link) and text mode (display:"text" β a wrapping, scrollable block; the body is the first non-empty of text/list_text/label). Extra keys are carried through harmlessly for future renderers. The convention favors number mode: title + one big number + one note + a source link, splitting multi-metric cards into several measures.
What a measure can do
compute() runs in vita's Python environment with PYTHONPATH=scripts, so it can import any vita module (from utils import β¦, from search import β¦, etc.), read files under data/ (use safe_read_json for resilience), make HTTP requests, or shell out to external CLIs for live data. This is deliberately open: a measure is a small data pipeline, and a canvas is whatever set of pipelines you point at it.
Guidance baked into the canvas-measure skill:
- Keep
compute()under ~60s (the refresh subprocess timeout is 120s, but the UI feels best when fast). - Prefer relative windows over hardcoded dates so the value stays meaningful on refresh.
- Don't catch-and-return-0 on pipeline failure β let
compute()raise. The runner persists the traceback tolatest.json["error"]and the UI shows "script error", which is the correct signal for a broken pipeline. - Only the runner writes
latest.json/history.jsonl; you only authorscript.py.
Running a measure
PYTHONPATH=scripts uv run python -m canvases.run_measure <project_id> <measure_id>
Stdout is always JSON ({result, error, refreshed_at}) so it can be consumed by the API or by an agent during authoring. Exit codes:
| Code | Meaning |
|---|---|
0 |
Success β result persisted |
1 |
Measure not found / script.py missing (JSON only on stderr) |
2 |
compute() raised β the error/traceback is persisted to latest.json |
The API refresh endpoints invoke this runner as a subprocess (_run_measure_subprocess) with cwd = vita root and PYTHONPATH=scripts, so a refresh inherits the host environment exactly the way a manual run does.
API
Editable endpoints are registered from api/src/routers/canvases.py; public token endpoints from api/src/routers/canvases_public.py. Both mount on the main API (port 33800, ./run api).
Projects, canvases, docs
| Method & path | Purpose |
|---|---|
GET /api/v1/canvases/projects |
List projects (?include_archived=true to include archived) |
POST /api/v1/canvases/projects |
Create a project ({name, description?}) |
GET /api/v1/canvases/projects/{pid} |
Get a project |
PATCH /api/v1/canvases/projects/{pid} |
Rename / re-describe / archive |
GET /api/v1/canvases/projects/{pid}/canvases |
List tabs (ordered by position) |
POST /api/v1/canvases/projects/{pid}/canvases |
Create a tab (seeds an empty v2 doc) |
PATCH /api/v1/canvases/projects/{pid}/canvases/{cid} |
Rename / reposition a tab |
DELETE /api/v1/canvases/projects/{pid}/canvases/{cid} |
Soft-delete a tab |
GET /api/v1/canvases/projects/{pid}/canvases/{cid}/doc |
Get the plat doc ({"doc": β¦}; doc is null until first save) |
PUT /api/v1/canvases/projects/{pid}/canvases/{cid}/doc |
Save the full doc ({"doc": {format, version, nodes}}) |
Measures
| Method & path | Purpose |
|---|---|
GET β¦/projects/{pid}/measures |
List measures (with latest, has_script) |
POST β¦/projects/{pid}/measures |
Create a measure (stub script if none supplied) |
GET β¦/projects/{pid}/measures/{mid} |
Get one measure (+ latest) |
GET β¦/projects/{pid}/measures/{mid}/script |
Get the script.py source |
PATCH β¦/projects/{pid}/measures/{mid} |
Rename / re-describe |
DELETE β¦/projects/{pid}/measures/{mid} |
Delete the measure directory |
POST β¦/projects/{pid}/measures/{mid}/refresh |
Re-run one measure |
POST β¦/projects/{pid}/canvases/{cid}/refresh |
Re-run every measure referenced by shapes on this canvas |
canvas/refresh resolves the referenced measure ids from the doc (referenced_measure_ids walks the doc's nodes for type:"measure"), then runs each. A measure that has been deleted but is still referenced returns {"error": "measure deleted"} rather than crashing the refresh.
Assets
A canvas image lives as a server-side blob, not in the browser: the editor PUTs a blob keyed by an asset:<id> pointer and stores the returned URL in the doc's image node, so images stay visible across browsers and in read-only shares.
| Method & path | Purpose |
|---|---|
PUT β¦/projects/{pid}/assets/{asset_id} |
Upload a blob (raw body; Content-Type + optional X-Asset-Name header). Returns {src}. |
GET β¦/projects/{pid}/assets/{asset_id} |
Fetch the blob (long-lived Cache-Control: immutable) |
- Single-blob cap: 25 MB (
MAX_ASSET_BYTES); a larger PUT returns413. - Blobs are written atomically (temp file β
fsyncβos.replace) with a sidecar.meta.json(content-type, name). - The asset filename is sanitized from the asset id (
asset:1f3aβ¦β safe filename) via a strict allowlist regex. GETis intentionally unauthenticated so the samesrcURL works for the owner and is mirrored read-only for public shares.
Public sharing
A single canvas tab can be shared read-only via a per-canvas token (not per-project, so sharing one tab never exposes the rest of the project). The token is registered in the shared-reports registry (data/reports/.shares.json) with kind:"canvas", alongside the reports share tokens, and the public URL is https://vita-reports.ham.xyz/cv/{token}.
| Method & path | Purpose |
|---|---|
POST /api/v1/canvases/share |
Create (or reuse) a token for {project_id, canvas_id, title?} β {token, url} |
GET /api/v1/canvases/public/{token}/meta |
Public canvas metadata |
GET /api/v1/canvases/public/{token}/doc |
The v2 doc + cached measure results bundle (doc null β viewer falls back to legacy snapshot) |
POST /api/v1/canvases/public/{token}/refresh |
Token-scoped refresh: re-run only this canvas's measures, return the fresh bundle |
GET /api/v1/canvases/public/{token}/assets/{asset_id} |
Serve an asset for the shared canvas (token-scoped path) |
How it stays safe and read-only:
share_canvasreuses an existing token if one already points at the same(project, canvas)pair, so re-sharing is idempotent.- The public doc/measure responses serve cached values (
latest.json) β they never expose the editable project endpoints. - The public refresh re-runs only the measures referenced by this canvas, capped to 5 concurrent worker threads. It is read-only-safe because it touches only measure result caches, never the canvas doc. A single failing measure is swallowed so it can't fail the whole refresh.
- The public viewer rewrites editor asset
srcURLs to the token-scopedβ¦/public/{token}/assets/{asset_id}path so the guarded share domain can fetch images. - Tokens optionally carry
expires_at; an expired link returns410.
Note: the public router's module docstring still claims "Refresh is NOT exposed publicly"; that is stale β a token-scoped public refresh endpoint exists (and is read-only-safe as described above). Code is the source of truth.
Authoring a measure
The canvas-measure skill (.claude/skills/canvas-measure/SKILL.md) drives measure authoring and auto-applies when a measure id like m_xxxxxxxx is mentioned. The loop:
- bio-zack creates a measure stub in the web UI (
/canvases/{project}, the measures panel β the sliders icon in the header). This mints them_β¦id and the measure directory. - The agent confirms
(project_id, measure_id)and thatdata/canvases/<pid>/measures/<mid>/exists. - The agent authors
script.py(only that file β nevermeta.json,latest.json, orhistory.jsonl). - Test before persisting via the API with the runner command above; iterate until exit
0andresult.valuelooks right. - bio-zack refreshes from the UI to confirm the round trip.
Measures are project-scoped on purpose; to reuse one across projects, create a stub in the second project and copy script.py into its measure directory.
Control flow at a glance
web UI /canvases/{projectId} β <Plat> (vita host adapter)
β autosave (full doc)
βΌ
PUT β¦/canvases/{cid}/doc βββββββββββββββΊ <cid>.plat.json (atomic)
β
refresh βββΊ POST β¦/canvases/{cid}/refresh
β ββ referenced_measure_ids(doc) β for each m_β¦:
β run_measure.py (subprocess) β compute()
β ββ latest.json (atomic) + history.jsonl (append)
βΌ
share βββΊ POST /api/v1/canvases/share β token in .shares.json (kind=canvas)
β vita-reports.ham.xyz/cv/{token}
(cached doc + measure bundle, read-only)
File Locations
| Path | Role |
|---|---|
api/src/routers/canvases.py |
Editable API: projects, canvases, docs, measures, assets, refresh |
api/src/routers/canvases_public.py |
Public token-scoped read API (/cv/{token} backend) |
scripts/canvases/core.py |
Storage layer: JSONL last-wins, docs, measures, assets, id validation |
scripts/canvases/run_measure.py |
Measure runner (import β compute() β persist) |
scripts/canvases/autolayout.py, reflow.py |
Python port of the auto-layout engine + headless settle helper |
~/work/plat (github.com/zackham/plat) |
The embeddable canvas library (engine, host contract, docs) |
web/src/features/canvases/ |
Vita's host layer: vitaHost.ts, publicHost.ts, panels, hooks |
web/src/routes/canvases/$projectId.tsx |
Editor route (mounts <Plat> with the vita adapter) |
web/src/routes/cv.$token.tsx |
Public read-only viewer route |
.claude/skills/canvas-measure/SKILL.md |
Measure authoring skill |
data/canvases/ |
All canvas state (per-project subdirs) |
data/reports/.shares.json |
Share-token registry (shared with reports) |
Where to go next
- Reports β the share-token mechanism and
vita-reports.ham.xyzdomain canvases reuse. - Architecture β router/storage conventions, atomic-write utilities.
- Data Browser β inspecting the
data/canvases/tree.