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 examplelens=notesorlens=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
| Lens | Purpose |
|---|---|
| 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:
| Attribute | Purpose |
|---|---|
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 / detail | Slots 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 supportcards,list,table, orkanban. Built-in-only layouts (utility,calendar,canvas) stay reserved for shipped affordances.query:store(type/tags),cms(collections),assets(categories), orfiles(extensions).source: set automatically toworkspacewhen 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.
| Attribute | Example | Meaning |
|---|---|---|
data-ui-region | shell.sidebar, shell.panel | Tier-1 shell band (canvas, chrome, sidebar, panel, companion) |
data-ui-pattern | layout.route | Tier-2 layout recipe |
data-ui-slot | header, content, detail | Slot within a pattern |
data-ui-component | app.cms.entries-table | Tier-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
startAtthroughendAt(all-dayYYYY-MM-DDranges 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/saveandPOST /trellis/calendar/delete; listed withGET /trellis/calendar/events(optionalyearandmonthquery 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:
| Action | Purpose |
|---|---|
list | Optional year + month (1–12) to filter one calendar month |
create | title, startAt (ISO datetime or YYYY-MM-DD with allDay: true), optional endAt (multi-day all-day or timed end), description, color |
update | Patch fields by event id |
delete | Remove 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
.basedefinition 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_OPTIONSdisables export, load, clear, theme toggle, and other items that normally live in the hamburger menu (UIOptions.canvasActions).showWelcomeScreen: falseandopenSidebar: nullon load so the welcome overlay and library sidebar do not reopen from saved state.- Shared spacing
excalidraw-host.cssdefines--studio-whiteboard-hud-inset(default0.75rem),--studio-whiteboard-hud-gap, and--studio-whiteboard-island-paddingso 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.5remvia--studio-whiteboard-inspector-width.
- Maps Excalidraw CSS variables to Studio tokens (
excalidraw-popover-flip.tscolor 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 viaupdateScene({ appState: { scrollX, scrollY } }). Redraws arerequestAnimationFrame-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
widthandheightfor standaloneautoResizetext (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).
- From the turtlecode repo root:
bun install(installs@excalidraw/excalidraw,react, andreact-dom). - Restart the Studio dev server and hard-refresh the browser.
- If it persists, clear Excalidraw
localStoragekeys (see below) and reopen the file.
If the canvas crashes on load
Symptom: console error collaborators.forEach is not a function.
- Restart the Studio dev server after pulling the latest build.
- 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));
- Reopen the
.whiteboardfile. The next save writes a clean JSON copy withoutcollaborators.
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 awhiteboard:*store entity, and selects it. Agent session scratch may use.trellis/sketch/<slug>.whiteboard(gitignored). Legacy boards underwhiteboards/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
.whiteboardextension; Delete removes the file after confirmation (samefile.rename/file.removehelpers 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
whiteboardtool (or any write to that.whiteboardfile), Studio reloads the file and applies the scene with ExcalidrawupdateSceneso 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:
catalogRevisionbumps when the file watcher reports add or unlink (create, delete, rename) and afterfile.rename()/file.remove()complete.useWorkspaceFileList(loader)wrapscreateResourceso lists refetch when the catalog changes (Whiteboards projection uses this for/find/filediscovery).useWorkspaceFileWatcher({ match, kinds })bumps a local generation for derived indexes (for example the.basevault 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
.whiteboardeditor 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
@mentiona 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 kind | ID prefix | Examples |
|---|---|---|
| template: | template. | template.sprint-retro, template.flow-diagram, template.system-context |
| figure: | figure. | figure.mindmap-node, figure.api-endpoint |
| layout: | layout. | layout.architecture-layers |
| primitive | primitive. | primitive.rectangle, primitive.arrow, primitive.text |
Tool actions:
| Action | Purpose |
|---|---|
list_catalog | List corpus entries (optional kind: template, figure, layout, primitive) |
describe | Semantic summary of a .whiteboard file (figures, bindings, untagged shapes) |
apply_template | Start from or merge a template.* scene (replace: true overwrites the board) |
insert_figure | Place 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:
whiteboard→apply_templateon@canvases/retro.whiteboardwithtemplateId: template.sprint-retroinsert_figurewithfigureId: figure.mindmap-node,label: "Auth",bind: issue:42describeto 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
- 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.
- Run
bun installfrom the turtlecode repo root so@opencode-ai/whiteboardresolves for OpenCode. - Send a new composer message while a
.whiteboardfile is open (projection or Code tab), or@the file path. - Ask explicitly to run
whiteboard→list_catalogbefore editing the board.
Some free models still refuse tool calls occasionally; switching models or starting a fresh session usually clears it.
Related
- Studio features session layout, file search, and core rail
- What's new recent projection and composer updates
- CLI commands
trellisfrom any project directory with-p