Projections and whiteboards

Domain lenses on the session icon rail, plus Excalidraw-backed .whiteboard files in the editor.

Studio separates activity modes (what you are doing) from projections (which slice of the project you want to see). Core rail icons answer activity; projections answer focus.

Icon rail layout

CORE (top → bottom)                  PROJECTIONS (pinned, per workspace)
Graph · Plan · Code · Database ·     Notes · Calendar · Whiteboards · … · [ + ]
Assets · Design · Review · Preview ·
Logs
         ↑ divider                              ↑ pin picker

Core activity views (top to bottom): Graph, Plan, Code, Database (?view=cms), Assets, Design, Review, Preview, Logs. Preview uses ?view=browser in the URL; ?view=preview is an alias.

  • Core routes use ?view=graph, ?view=code, and similar.
  • Projections use ?view=projection&lens=<id> (for example lens=notes or lens=whiteboards).
  • + opens a pin picker grouped as On rail and Add to rail (up to eight pins; at least one required). Hover a pinned icon to reveal × in the corner and unpin without opening the menu (keyboard focus on the icon shows × as well).

Projections filter and present data. They do not replace full CMS (schema and collections), Assets (media library), or Plan (issues). Use Plan for the full issue board, milestones, and suggestions. Plan → Calendar and the Calendar projection both mount the same CalendarView module (day sidebar, month grid, modal event editor, manual calendar_event entities).

Built-in projections

LensPurpose
Notes:Quick-capture cards backed by the Trellis store (type:note). Always pinned by default.
Whiteboards:Excalidraw diagrams stored as .whiteboard files in the repo.
Calendar:Monthly grid: graph issues/work units/milestones plus manual events. Pinned by default (second slot after Notes) for all workspace templates.
Content, Records, Posts, …CMS-filtered tables and cards by collection (workspace-type defaults).

Default pins depend on session template / workspace type. Every template includes Notes and Calendar; app workspaces also pin Whiteboards, Records, and Content (up to eight icons, left to right). Override in opencode.jsonc:

{
  "projections": {
    "workspaceType": "app",
    "pinned": ["notes", "calendar", "whiteboards", "records", "content"],
  },
}

To hide Calendar on the rail, omit it from pinned (keep at least one projection).

User pin order is also stored per project in localStorage (session.projections.pinned.<projectId>).

Affordance layout shell

Pinned projections (affordances) share a compositor, AffordanceShell, so master-detail routes use the same chrome: optional sidebar, header (title or tabs), scrollable content well, and optional detail drawer. The shell maps to the layout.route pattern and emits stable DOM hooks for agents and visual tests:

AttributePurpose
data-ui-pattern="layout.route"Route panel frame
data-ui-region="panel"Working area elevation
data-ui-affordance="<lens-id>"Which projection is mounted (for example notes, clock)
data-ui-slot="header" / content / detailSlots inside the pattern

Notes, CMS-filtered projections, and Clock mount through AffordanceShell today. Calendar and Whiteboards keep bespoke renderers but follow the same route slots where possible. Clock uses the utility layout (tabbed single-pane tools) instead of master-detail.

Stub or coming-soon lenses still render inside the shell with an empty state instead of breaking the rail.

Custom workspace projections

Declare filtered views in opencode.jsonc without new TypeScript. Workspace-defined affordances merge with the built-in registry and appear in the + pin picker when valid.

{
  "projections": {
    "pinned": ["notes", "calendar", "my-tickets"],
    "custom": [
      {
        "id": "my-tickets",
        "label": "My tickets",
        "description": "Support tickets for this workspace.",
        "layout": "table",
        "icon": "records",
        "query": { "kind": "cms", "collections": ["tickets"], "match": "exact" },
        "create": { "label": "New ticket", "collection": "tickets" },
      },
    ],
  },
}

Rules:

  • id: lowercase slug (a-z, digits, hyphens); must not collide with built-in lenses (notes, calendar, …).
  • layout: workspace entries support cards, list, table, or kanban. Built-in-only layouts (utility, calendar, canvas) stay reserved for shipped affordances.
  • query: store (type/tags), cms (collections), assets (categories), or files (extensions).
  • source: set automatically to workspace when parsed from config.

Custom CMS-style affordances route through the same CmsProjection + AffordanceShell path as built-in collection lenses.

UI ontology (agent-facing)

Studio exposes a small DOM vocabulary so agents, browser automation, and visual QA share names for regions and composites. Inspect in order: region → pattern → slot → component.

AttributeExampleMeaning
data-ui-regionshell.sidebar, shell.panelTier-1 shell band (canvas, chrome, sidebar, panel, companion)
data-ui-patternlayout.routeTier-2 layout recipe
data-ui-slotheader, content, detailSlot within a pattern
data-ui-componentapp.cms.entries-tableTier-3 composite (target during migration)

Elevation uses structural tokens (bg-canvas, bg-sidebar, bg-well, bg-elevated, …). Legacy --background-* aliases remain for one release.

Internal indexes (not published on trellis.computer): packages/ui/ONTOLOGY.md (primitives), packages/app/ONTOLOGY.md (composites), spec studio/specs/ui-elevation-ontology.md.

Calendar projection

Select Calendar in the projection zone (or open ?view=projection&lens=calendar). The same layout is available under Plan → Calendar (calendar-view.tsx).

Layout (matches Whiteboards and other route-based panels):

  • Left sidebar agenda for the selected day (defaults to today): manual events with time range labels, plus New event in the footer.
  • Main area month grid with previous/next navigation in the header and a legend (events, issues, work units, milestones).

Create and edit use a modal dialog (CalendarEventDialog), not a right-hand detail drawer.

Graph-backed markers (read-only on the grid):

  • Priority-colored dots for issues (round) and work units (square) at the bottom of each day cell.
  • Milestones as interactive-colored squares on their created date.

Manual events (calendar_event entities in the Trellis store):

  • Click a day to change the sidebar agenda; click an event chip (grid or sidebar row) to open the edit dialog.
  • Double-click a day or New event (sidebar footer or header) to create an event.
  • The grid shows up to two event title chips per day; extra events appear as +N more (click to select that day in the sidebar).
  • Multi-day events span every day from startAt through endAt (all-day YYYY-MM-DD ranges and timed ranges with distinct end dates).
  • In the dialog: title, description, all-day vs timed start/end, optional end date and time, and color (default, critical, high, medium, low). Save, delete (existing events), or cancel.
  • Persisted via POST /trellis/calendar/save and POST /trellis/calendar/delete; listed with GET /trellis/calendar/events (optional year and month query params, month 1–12).

Implementation: packages/app/src/lib/calendar/event-model.ts (eventOccursOnDay, formatEventTime, local date/time helpers), calendar-view.tsx, calendar-event-dialog.tsx, calendar-projection.css.

Agents calendar tool

OpenCode registers a calendar tool for CRUD on the same calendar_event entities:

ActionPurpose
listOptional year + month (1–12) to filter one calendar month
createtitle, startAt (ISO datetime or YYYY-MM-DD with allDay: true), optional endAt (multi-day all-day or timed end), description, color
updatePatch fields by event id
deleteRemove by id

Example: create an all-day event on 2026-06-03 with startAt: "2026-06-03", allDay: true, color: "high".

When capture.personalEvents is on (default), agent calendar create actions for all-day birthdays or anniversaries also persist a year-stripped fact to user memory in code. See Agent memory and capture.

Manual events are linked to the project entity with knows (same pattern as notes). They are not stored as files on disk.

Obsidian Bases (.base files)

.base files are YAML definitions (Obsidian Bases format) that describe filtered views over markdown notes in the repo. Open any *.base path in Code view.

Rich view vs YAML source

The editor tab bar includes a mode toggle (table icon / code icon), like markdown rich vs source:

  • Rich (default): table or card layout with columns from the base definition, optional multiple named views, filters, and sort order. Click a row to open the note in a file tab.
  • YAML source:: Monaco editor for the .base definition itself.

Studio keeps both surfaces mounted for .base tabs so switching rich ↔ YAML does not tear down the panel or rescan the vault.

Vault index and live refresh

The rich view walks the workspace once per project and caches the note index (frontmatter + file metadata). Reopening a .base file or switching between bases reuses the cache instead of reloading the whole UI.

The cache invalidates when the file watcher reports a change to any .md, .markdown, or .mdx file, then the table refreshes on the next render.

Implementation: packages/app/src/lib/obsidian-base/ (walkVault, parse, vault-cache).

Whiteboards (.whiteboard files)

Whiteboards are first-class project files, not store entities. Each file holds Excalidraw JSON (same shape as a native .excalidraw export):

{
  "type": "excalidraw",
  "version": 2,
  "elements": [],
  "appState": {
    "viewBackgroundColor": "#ffffff",
    "gridSize": 20,
    "gridStep": 5,
    "gridModeEnabled": false,
    "showWelcomeScreen": false
  },
  "files": {}
}

New boards default to a uniform 20px dot grid (Trellis overlay; Excalidraw’s line grid stays off). gridStep is stored for Excalidraw compatibility but does not change dot styling (no brighter 5×5 subgrid). Set "gridSize": null in appState to hide the dots.

Open in Code view

Open any *.whiteboard path in Code view. Studio renders the Excalidraw editor instead of Monaco. Changes auto-save to disk (debounced) and integrate with the tab Save action.

Dot grid (pan and zoom)

ExcalidrawHost paints a uniform dot matrix on a canvas above the Excalidraw surface (20px spacing at normal zoom). Every dot uses the same size and opacity; there is no 5×5 “major” subgrid.

Dots follow the same scroll and zoom as the board. resolveDotGridLod thins the grid when you zoom out past ~45% (wider spacing, lower opacity; hidden below ~5% zoom) so the canvas does not turn into visual noise. The overlay is non-interactive. Excalidraw’s built-in line grid stays off (gridModeEnabled: false) so you do not get two grids. Set gridSize to 0 in appState to hide dots.

Implementation: whiteboard-dot-grid.ts (resolveDotGridLod, paintWhiteboardDotGrid), excalidraw-host.tsx, excalidraw-host.css.

Studio HUD (left tool rail + right inspector)

Excalidraw 0.18 does not expose APIs to dock the toolbar or properties panel. Trellis trims and repositions chrome inside ExcalidrawHost:

  • STUDIO_WHITEBOARD_UI_OPTIONS disables export, load, clear, theme toggle, and other items that normally live in the hamburger menu (UIOptions.canvasActions).
  • showWelcomeScreen: false and openSidebar: null on load so the welcome overlay and library sidebar do not reopen from saved state.
  • Shared spacingexcalidraw-host.css defines --studio-whiteboard-hud-inset (default 0.75rem), --studio-whiteboard-hud-gap, and --studio-whiteboard-island-padding so the tool rail, inspector, zoom stack, and minimap align to the same edge inset and island padding.
  • excalidraw-host.css (scoped under .excalidraw-host):
    • Maps Excalidraw CSS variables to Studio tokens (--surface-raised-base, --border-weaker-base, --text-strong, and similar) so islands match the surrounding UI.
    • Hides the hamburger, Library, and Help controls (file operations and theme live in Studio, not Excalidraw).
    • Docks the drawing toolbar as a vertical rail on the left (selection, shapes, pen, hand, extra tools).
    • Stacks zoom (− / % / +) and undo/redo vertically in the bottom-left column, above the minimap (same inset as the tool rail).
    • Moves the properties panel (stroke, fill, opacity, layers) to the right edge, full editor height, when a shape is selected (Figma-style inspector). Width defaults to 12.5rem via --studio-whiteboard-inspector-width.
  • excalidraw-popover-flip.ts color pickers, font lists, and other inspector popovers default to opening to the right of their triggers (Excalidraw/Radix). With the inspector on the right edge, Studio flips overflowing popovers leftward so menus stay on screen.
  • whiteboard-minimap.ts: Excalidraw has no built-in minimap. Studio paints a 180×120 canvas overlay (same pattern as Graph view): each element drawn as a filled block scaled to its bounds (min 1.5px, themed via --text-interactive-base), a viewport rectangle with a dimmed mask over the off-screen area, click or drag to pan via updateScene({ appState: { scrollX, scrollY } }). Redraws are requestAnimationFrame-batched and DPR-aware.

The inspector, tool rail, zoom stack, and minimap overlay the canvas; they do not reserve persistent columns that shrink the drawable area.

Implementation: excalidraw-host.tsx, excalidraw-host.css, excalidraw-popover-flip.ts, whiteboard-minimap.ts.

Text auto-resize (agent edits)

Excalidraw only recalculates text box size while you edit in the canvas. When an agent (or any tool) updates text in the JSON file, the old width / height can leave content clipped even with "autoResize": true.

Studio normalizes text elements on load, save, and when applying agent or disk updates:

  • Recomputes width and height for standalone autoResize text (skips fixed-size and label-in-shape text).
  • Writes corrected dimensions back to disk when an external update changes the file.
  • Corpus inserts default new text to autoResize: true.

Agents can set text only; they do not need to manually resize boxes. Implementation: packages/whiteboard/src/text-layout.ts (normalizeTextElements), used from @opencode-ai/whiteboard/browser and excalidraw-react-bridge.tsx.

What gets saved

Persisted JSON includes elements, durable appState fields (for example background color and grid spacing), and embedded files. Studio strips runtime-only appState keys before save notably collaborators, which Excalidraw keeps as an in-memory Map, not JSON.

When opening a board, Studio passes collaborators: new Map() to Excalidraw, scopes browser storage with a per-file name (the workspace path), and repairs corrupted localStorage entries (including the default excalidraw key) by removing plain-object collaborators values left over from earlier sessions.

SolidJS and React

Studio is SolidJS; Excalidraw is a React component. They do not share one component tree. ExcalidrawHost (excalidraw-host.tsx) mounts a dedicated DOM node, loads the editor through loadExcalidrawBundle() (excalidraw-bundle.ts → lazy excalidraw-react-bridge.tsx), and uses React 18 createRoot inside it. The host passes UIOptions: STUDIO_WHITEBOARD_UI_OPTIONS and merged appState defaults from @opencode-ai/whiteboard/browser. excalidraw-host.css scopes layout (full-height host, dot-grid layer, Studio HUD) and restores Excalidraw toolbar button styles inside .excalidraw-host when Tailwind preflight is active on the page.

Agent or disk updates bump sceneRevision so the host applies the new scene with updateScene without remounting React.

If the canvas looks broken (toolbar only, black canvas)

Symptom: shape icons stack vertically, the drawing surface is empty or black, or layout looks unstyled. Excalidraw’s JavaScript loaded but @excalidraw/excalidraw/index.css did not (common after a partial install or before restarting dev).

  1. From the turtlecode repo root: bun install (installs @excalidraw/excalidraw, react, and react-dom).
  2. Restart the Studio dev server and hard-refresh the browser.
  3. If it persists, clear Excalidraw localStorage keys (see below) and reopen the file.

If the canvas crashes on load

Symptom: console error collaborators.forEach is not a function.

  1. Restart the Studio dev server after pulling the latest build.
  2. Clear bad browser storage for your origin (DevTools → Application → Local Storage), or run in the console:
Object.keys(localStorage)
  .filter((k) => k.startsWith("excalidraw"))
  .forEach((k) => localStorage.removeItem(k));
  1. Reopen the .whiteboard file. The next save writes a clean JSON copy without collaborators.

Dependencies

The editor uses @excalidraw/excalidraw@0.18.0 with React 18.3 (react / react-dom in packages/app). Vite prebundles those packages and injects Excalidraw CSS through the React bridge module.

Whiteboard file helpers live in @opencode-ai/whiteboard (packages/whiteboard/). The Studio UI imports the browser entry (@opencode-ai/whiteboard/browser) so Vite does not bundle the Node-only corpus loader (node:fs). OpenCode agents use the full package entry, which includes corpus JSON and the whiteboard tool.

After pulling Studio:

cd studio   # or your turtlecode/ide clone
bun install

Restart the dev server so Vite picks up the whiteboard chunk and workspace links. Whiteboards require installed node_modules; there is no CDN fallback.

If the Whiteboards lens fails to load (500 on schema.ts)

Symptom: browser console shows GET …/lib/whiteboard/schema.ts 500 or Failed to fetch dynamically imported module for session.tsx.

Cause: Studio accidentally imported the main @opencode-ai/whiteboard entry in the browser (pulls node:fs corpus code). Current builds use @opencode-ai/whiteboard/browser with a Vite alias in packages/app/vite.js.

Fix: pull latest Studio, run bun install from the repo root, restart the dev server, and hard-refresh.

Whiteboards projection

Select Whiteboards in the projection zone (or open ?view=projection&lens=whiteboards):

Layout matches Plan: a persistent left sidebar (224px) lists every .whiteboard file in the workspace (discovered via /find/file). The main pane is the Excalidraw editor for the selected board.

  • New whiteboard (sidebar footer) creates @canvases/<slug>.whiteboard (numeric suffix if the name exists), registers a whiteboard:* store entity, and selects it. Agent session scratch may use .trellis/sketch/<slug>.whiteboard (gitignored). Legacy boards under whiteboards/ still appear in the list.
  • Switch boards from the sidebar without leaving the projection lens.
  • Rename or delete: hover a board row (or select it) and open the menu on the right. Rename keeps the .whiteboard extension; Delete removes the file after confirmation (same file.rename / file.remove helpers as the file tree). The sidebar list and labels refresh immediately; no page reload required.
  • Auto-save: canvas edits save to disk about 400ms after you stop drawing. The toolbar shows Saving…, Saved, or Unsaved (no manual Save button in projection mode).
  • Live sync: when an agent updates the open board via the whiteboard tool (or any write to that .whiteboard file), Studio reloads the file and applies the scene with Excalidraw updateScene so changes appear without refreshing the page.

To edit the same file from the file tree or a Code tab, open *.whiteboard in Code view. Code view uses the same auto-save and agent sync behavior; Save now is available for an immediate flush.

Workspace file lists (shared refresh)

Projection sidebars and other workspace-scoped file indexes share one refresh signal in the file context:

  • catalogRevision bumps when the file watcher reports add or unlink (create, delete, rename) and after file.rename() / file.remove() complete.
  • useWorkspaceFileList(loader) wraps createResource so lists refetch when the catalog changes (Whiteboards projection uses this for /find/file discovery).
  • useWorkspaceFileWatcher({ match, kinds }) bumps a local generation for derived indexes (for example the .base vault cache on markdown path changes).

Rename and delete from the file tree, drag-move, or projection menus all route through the same file helpers, so every lens stays in sync without manual refresh.

Implementation: packages/app/src/context/file/catalog.ts, packages/app/src/hooks/use-workspace-file-{catalog,list,watcher}.ts.

Agents and the whiteboard tool

OpenCode registers a whiteboard tool for every default agent (same pattern as cms, calendar, and asset). The Trellis system prompt lists corpus actions so models do not claim they lack drawing tools.

Composer context (automatic):

  • Code view: open .whiteboard editor tabs (up to three recent paths) attach on send.
  • Whiteboards projection: the board selected in the sidebar attaches the same way (no @ required).
  • You can still @mention a path or name the board in the prompt.

Server hints: when .whiteboard files are in context or your message mentions whiteboards, diagrams, Excalidraw, flowcharts, or similar, OpenCode adds a synthetic <whiteboard-tool> reminder to use list_catalog, apply_template, and insert_figure.

Studio agents should use the built-in whiteboard tool and the versioned corpus in @opencode-ai/whiteboard (server-side) instead of hand-editing raw Excalidraw element JSON. The browser editor does not load the corpus at runtime; agents apply templates and figures through the tool, which writes .whiteboard files to disk. Prefer stable corpus slugs (template.*, figure.*, layout.*, primitive.*) over opaque element id values.

Corpus kindID prefixExamples
template:template.template.sprint-retro, template.flow-diagram, template.system-context
figure:figure.figure.mindmap-node, figure.api-endpoint
layout:layout.layout.architecture-layers
primitiveprimitive.primitive.rectangle, primitive.arrow, primitive.text

Tool actions:

ActionPurpose
list_catalogList corpus entries (optional kind: template, figure, layout, primitive)
describeSemantic summary of a .whiteboard file (figures, bindings, untagged shapes)
apply_templateStart from or merge a template.* scene (replace: true overwrites the board)
insert_figurePlace a figure.*, layout.*, or primitive.* at canvas x / y with optional label and graph bind

Graph bindings are stored on elements as customData.trellis (for example bind: "issue:42", corpusId: "figure.mindmap-node"). describe reports them as binds:issue:42 for readability. Link boards to issues, entities, or work units so agents and humans can cross-navigate between the graph and the canvas.

Example flow:

  1. whiteboardapply_template on @canvases/retro.whiteboard with templateId: template.sprint-retro
  2. insert_figure with figureId: figure.mindmap-node, label: "Auth", bind: issue:42
  3. describe to verify structure before finishing the turn

Corpus source lives under packages/whiteboard/corpus/ in the turtlecode repo. Internal spec: studio/specs/whiteboard-ontology.md.

If the agent says it has no whiteboard tools

  1. Restart the OpenCode backend (port 4096) and the Studio dev server after pulling whiteboard changes; tool registration and system-prompt updates load at process start.
  2. Run bun install from the turtlecode repo root so @opencode-ai/whiteboard resolves for OpenCode.
  3. Send a new composer message while a .whiteboard file is open (projection or Code tab), or @ the file path.
  4. Ask explicitly to run whiteboardlist_catalog before editing the board.

Some free models still refuse tool calls occasionally; switching models or starting a fresh session usually clears it.