Canvases and widgets
Apps in Project88 are either native shell surfaces or React Flow canvases you can extend with draggable widgets — with per-canvas permissions and per-user view state.
Project88 has two kinds of app surfaces that both render as peers of the top nav:
- Native apps — full-bleed shell surfaces with their own opinionated
layout (rail + grid, mini-month + sidebar + week, folder rail + list +
reader, …). Dial, Pages, Data, Calendar, and Inbox ship as
native today. They don't sit on a React Flow board and don't get a
canvasesrow. - Canvases — blank, infinite React Flow boards you populate with widgets: draggable, resizable nodes that surface live org data. The per-org Home canvas and any user-created board are canvases.
Every entry in the app registry (APP_CANVAS_CONFIG) carries a
kind: 'native' | 'canvas' field — single source of truth for which
surface mounts. A small native-apps/registry.js maps each native mode
to its { Surface, Skeleton }; the showNativeApp branch in
CanvasWorkspace (dispatched by a pure selectShellView()) skips the
canvas mount entirely. On native surfaces the top bar hides the canvas
toolbar, New / Edit Canvas, and the pan / select tool picker.
NativeSurface accepts either a plain header slot (header stacked
over the inset white card) or a sidebar slot (full-height left
column, with the right column owning its own header + grid). Data,
Calendar, and Dial all opt into the sidebar shape via a
nativeLayout flag on their workspace body — the table-list rail,
the mini-month + My Calendars rail, and the active-lead contact card
respectively. The right column carries the app's primary toolbar /
nav on the chrome's darker top bar via the shared NativePaneChrome
helper in widgetChrome.jsx, with the body in the inset white card
below. The canvas mounts for the same widgets keep their single-column
chrome — the sidebar layout is the native-surface opt-in only.
Widget node types stay registered after the migration, so canvases
you already populated keep rendering their old DialWidget,
DataTableWidget, EmailInboxWidget, calendar widgets, etc. And the
dashboard-style widgets (Data stats / record, Calendar agenda / month /
year / heatmap / stats, Email inbox / compose / stats / accounts,
DialStats / DialBucketList) remain addable to any canvas via the global
Add Widget menu — Home, your own boards, anywhere.
The hierarchy is flat: org → canvas. There's no intermediate "workspace"
layer — every canvas hangs directly off your organization, and the native
apps mount alongside them as peer surfaces.
Canvases
Each canvas is identified by a (org_id, type, slug) tuple and persisted
to the canvases table:
type—boardfor user-created canvases and the per-org Home canvas;orchestration,agent_pipeline,agent_configfor system canvases.slug—home, or a user-chosen name (prefixedcanvas-in the URL). The reserved native-app slugs (pages,data,calendar,email,dial) never write acanvasesrow anymore — their surfaces are mounted directly by the shell.nodes_json/edges_json— the React Flow graph.viewport_json— the org-shared anchor viewport.visibility—'org'(the default — every member can see it) or'private'(only explicit members).is_system—truefor the protected Home canvas. Rename, icon, slug, type, visibility, andis_systemitself are locked by theprotect_system_canvastrigger; content fields (nodes_json,edges_json, etc.) stay editable so Home can still hold dashboards.
Nodes and edges auto-save with an 800 ms debounce. Reopening a canvas restores the exact layout.
URL shape
/:orgSlug — lands on Home (canvas)
/:orgSlug/<appSlug> — reserved app surfaces (pages, data,
calendar, email, dial — all native)
/:orgSlug/canvas-<userSlug> — user-created canvasesThe old /:orgSlug/:wsSlug/... URL shape is gone — bookmarks redirect
through PostAuthLanding to the new shape. Native and canvas surfaces
share the same URL grammar; the shell decides which to mount from
APP_CANVAS_CONFIG[kind].
Home is special
Every org has exactly one Home canvas — auto-created by the
handle_new_org_home_canvas trigger when the org is created (and
backfilled for older orgs). It's pinned to slug = 'home',
is_system = true, and visibility = 'org'. Clicking the logo or hitting
⌘K → Home always lands you there.
You can drop widgets on Home like any other canvas — it's a full board, just renamed and protected. What you can't do is rename it, change its icon, make it private, or delete it. The "Manage" and "Delete" entries on the canvas dropdown are hidden for system canvases.
Per-canvas permissions
Toggle a canvas between org-wide and private from the canvas
dropdown → hover the canvas → Share (visible to org owners and
admins only). The ShareCanvasSheet is a Notion-style auto-save sheet:
- Anyone in the organization (
visibility = 'org') — every org member can see and edit, no member list shown. - Specific people (
visibility = 'private') — only listed members can see it. Each member gets a role: viewer (read) or editor (read + write).
Private canvases show a small lock icon in the dropdown. The Home canvas can't be made private — Share is hidden for system canvases.
RLS enforces the same gate at the database level via the
canvas_visible_to and canvas_writable_to security-definer helpers.
The UI gate is defense in depth.
Per-user view state
Pan / zoom is session-only and was always per-user. As of the per-user view-state work:
- Anchor viewport (where the canvas opens by default) is per-user.
- Default zoom is per-user.
They're stored in user_canvas_state ((user_id, canvas_id)). Two
affordances live in the canvas settings drawer → Canvas tab → Viewport:
- Reset to team default — deletes your row, so the org-shared anchor is what you'll see on next load. Click-again-to-confirm.
- Save as team default (org admin / owner only) — writes your
current anchor + zoom to the org-shared
canvasesrow. Doesn't overwrite teammates who've already customized — they keep their view until they Reset.
The merge rule is "user wins, fall back to canvas, then to hardcoded default."
Hidden apps in production
Each app in the canvas registry has a status:
| Status | Behavior |
|---|---|
live | Shipping today — visible in every environment |
in_progress | Visible in dev (import.meta.env.DEV), hidden in production |
hidden | Kill switch — off in every environment |
in_progress and hidden apps are filtered out of the sidebar, the
right-click "Add Widget" menu, and the toolbar widget picker. A deep
link to a hidden app redirects to Home. Already-placed widgets keep
rendering — filtering applies only at picker entry points so saved
canvases never lose data. The same invariant covers the native-apps
migration: dropping Dial / Pages / Data / Calendar / Email off the
canvas didn't unregister their node types, so any board that already
contained a DialWidget or a CalendarWeekWidget still paints it.
See Apps for the current live / in-progress list.
Editing
Canvases are read-only by default. Toggle edit mode from the canvas settings popup. In edit mode:
- Right-click a free spot → Add Widget → category → Small / Medium / Large size.
- Right-click an existing node → settings panel (background, border, shape, opacity, shadow, typography, handle configuration).
- Drag to move. The NodeResizer lets you resize.
- Keyboard shortcuts:
⌘Zundo,⌘⇧Zredo,⌘Dduplicate,⌘C/Vcopy/paste,Delete,⌘Aselect all,Escapedeselect. - Undo/redo keeps a snapshot history of up to 50 steps, including drag positions.
Widgets
A widget is a node that wraps a small interactive React app. Each widget ships in three sizes:
| Size | What you get |
|---|---|
| Small | Icon-only — quick visual indicator |
| Medium | Compact preview (mini-table, mini-form) |
| Large | Full interactive view |
Built-in widgets — data, pages:
- DataTableWidget — interactive table view
- DataTableListWidget — list of all tables; outputs
tableId - DataTableViewerWidget — paginated view; outputs
selectedRowId - DataRecordWidget — single-record card with prev/next nav
- DataStatsWidget — KPI cards (row count, fill %, latest update)
- PagesWidget — recents list + the full
PageWorkspace(tree sidebar + block editor) in the large variant. Tree rows and the editor opt out of React Flow gestures so drag-to-reorder, range text selection, and the I-beam cursor all work inside the canvas node instead of panning it.
Built-in widgets — calendar, tasks, pipeline:
- CalendarWeekWidget — week grid with drag-to-reschedule and drag-across-calendars
- CalendarMonthWidget — month grid
- CalendarAgendaWidget — agenda list with Now / Next status and per-event tag color
- TasksWidget — pull
kind = 'task'activities into a checklist; groups by overdue / due today / upcoming - PipelineStageWidget — kanban stages over records, conversations, events, or any taggable entity; rule-based filters
Built-in widgets — dial:
- DialWidget, DialBucketListWidget, DialStatsWidget, plus the supporting panels — see Dial.
Connecting widgets
Widgets declare a DATA_ROLE:
source— outputs data (e.g.DataTableListWidgetoutputstableId).both— accepts upstream input and emits output (e.g.DataTableWidgettakes atableIdand emits aselectedRowId).target— consumes upstream input only (e.g.DataRecordWidget,DataStatsWidget).
When you connect a source/both widget to a both/target widget, two values flow:
data.upstreamTableId— propagates downstream so the connected widget knows which table to render.data.upstreamRowId— emitted when you click a row in a table widget; downstream record widgets re-render to that row.
Connected widgets show a small ● Linked / ● Row Linked indicator. Disconnecting clears the upstream and restores manual selectors.
Handles
Each enabled handle is exactly one type:
- Source nodes — all handles blue (outgoing).
- Target nodes — all handles green (incoming).
- Both nodes — left handles green (targets), right handles blue (sources).
The settings panel handle picker shows colored dots matching the resolved
type. connectionMode="strict" ensures source→target only.
isValidConnection blocks self-connections and anchor edges.
The Planning canvas
A page can attach a planning canvas — a text-centric React Flow board
with Text, Task, Group, and Link nodes. It renders as an inline preview
above the page blocks; clicking opens the full-screen editor. Shares the
same EditorShell and FlowNodeShell infrastructure as the Automation
editor.
The Pipeline Stage widget
Worth calling out: the PipelineStageWidget is a kanban-style stage
widget that groups entities by stage. Stages are derived from tags or a
column value, and rules decide which entity belongs to which stage —
tag match, date range, numeric comparison, text match. Drag an entity
between stages to retag it.
Use it for sales pipelines (records → stages by opportunity status), support boards (conversations → stages by priority tag), or any kanban-shaped workflow over your data.
Widget linking primitive
A newer primitive (src/widgetLinks/) lets one widget's selection drive
another's filter — wire the Email Inbox widget to a Records widget
so clicking an email opens the linked customer record, for example. Links
are configured per pair of widgets and persist with the canvas.
Skeleton-first loading
Data-bearing widgets paint a layout-matched skeleton placeholder before real content arrives — on first load and on identity changes (record, table, folder, month, week, day, thread). The skeleton-first policy keeps the canvas from flashing centered spinners or blank space on routine view switches.
The mechanism:
<SkeletonRegion>owns ARIA, the pulse animation, and a stale-fade for slow networks. Leaf primitives are static so per-region paint cost stays O(1).useSkeletonGate({ identity, isLoading })encodes the trigger policy: skeleton on identity change, 120 ms minimum display (no cache-hit flash),staleflag after 8 s for slow networks.hasLoadedRows(tableId)on the data store distinguishes "never fetched" from "fetched, empty" — required for DataTable widgets to gate correctly so a brand-new viewer doesn't flash "Select a table" before its first skeleton.- A
--skeletontoken +bg-skeletonutility; the pulse honorsprefers-reduced-motionviamotion-safe:animate-pulse. - A drift-lint test (
__skeletons__.test.js) fails CI if a*WidgetSkeleton.jsxredeclares layout constants instead of importing from its sibling*.layout.js.
Where each widget keys its skeleton:
| Family | Identity |
|---|---|
| Dial | The active record |
folder, activeEmailId, activeThread.threadId | |
| DataTable | tableId (plus auto-select pending for the viewer) |
| Calendar | ${year}-${month} (month), toDateString() of the visible week start (week), upstream-selected day (agenda) |
| Tasks | constant — first-load gate only |
Post-mutation refetches (create / update / delete / tag) deliberately
do not re-fire the skeleton: optimistic updates already paint the
new state, and the existing header spinner remains the syncing
affordance. The calendar widgets gate on events.length === 0 for
exactly this reason.
A handful of widgets still use centered Loader2 spinners
(EmailAccountSelectorWidget, ConversationView*, ContactListWidget,
AgentsWidget, CanvasListWidget) — each migration is now mechanical
on top of the hook + primitives in place.
Where to go next
- Home
- Workflow basics — the Automation editor (same canvas tech, different node palette).