Core Concepts

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 canvases row.
  • 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:

  • typeboard for user-created canvases and the per-org Home canvas; orchestration, agent_pipeline, agent_config for system canvases.
  • slughome, or a user-chosen name (prefixed canvas- in the URL). The reserved native-app slugs (pages, data, calendar, email, dial) never write a canvases row 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_systemtrue for the protected Home canvas. Rename, icon, slug, type, visibility, and is_system itself are locked by the protect_system_canvas trigger; 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 canvases

The 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 canvases row. 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:

StatusBehavior
liveShipping today — visible in every environment
in_progressVisible in dev (import.meta.env.DEV), hidden in production
hiddenKill 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: ⌘Z undo, ⌘⇧Z redo, ⌘D duplicate, ⌘C/V copy/paste, Delete, ⌘A select all, Escape deselect.
  • 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:

SizeWhat you get
SmallIcon-only — quick visual indicator
MediumCompact preview (mini-table, mini-form)
LargeFull 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. DataTableListWidget outputs tableId).
  • both — accepts upstream input and emits output (e.g. DataTableWidget takes a tableId and emits a selectedRowId).
  • 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), stale flag 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 --skeleton token + bg-skeleton utility; the pulse honors prefers-reduced-motion via motion-safe:animate-pulse.
  • A drift-lint test (__skeletons__.test.js) fails CI if a *WidgetSkeleton.jsx redeclares layout constants instead of importing from its sibling *.layout.js.

Where each widget keys its skeleton:

FamilyIdentity
DialThe active record
Emailfolder, activeEmailId, activeThread.threadId
DataTabletableId (plus auto-select pending for the viewer)
Calendar${year}-${month} (month), toDateString() of the visible week start (week), upstream-selected day (agenda)
Tasksconstant — 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

On this page