Skip to content

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 at https://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 nodes array. 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 nodes array; 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:

  1. Write the frame + children with ascending index values, then run the reflow helper (a Python port of layout.ts) to rewrite every child's x/y and the frame's hug w/h:
    PYTHONPATH=scripts uv run python -m canvases.reflow <project_id> <canvas_id>
    
    Idempotent. (scripts/canvases/reflow.py; auto-layout math lives in scripts/canvases/autolayout.py / ~/work/plat/src/layout.ts.)
  2. Compute the final positions yourself and write settled x/y/w/h (keep the layout block 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 to latest.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 author script.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 returns 413.
  • 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.
  • GET is intentionally unauthenticated so the same src URL 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_canvas reuses 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 src URLs 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 returns 410.

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:

  1. bio-zack creates a measure stub in the web UI (/canvases/{project}, the measures panel β€” the sliders icon in the header). This mints the m_… id and the measure directory.
  2. The agent confirms (project_id, measure_id) and that data/canvases/<pid>/measures/<mid>/ exists.
  3. The agent authors script.py (only that file β€” never meta.json, latest.json, or history.jsonl).
  4. Test before persisting via the API with the runner command above; iterate until exit 0 and result.value looks right.
  5. 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.xyz domain canvases reuse.
  • Architecture β€” router/storage conventions, atomic-write utilities.
  • Data Browser β€” inspecting the data/canvases/ tree.