Apps

Pages

The Notion-style document mode — unified Pages workspace widget with tree, folders, and block editor side-by-side.

Pages is the writing surface. Pages opens as a native shell surface mounted by PagesSurface — full-bleed, no React Flow canvas underneath — and renders the same PageWorkspace component: a Notion-style page tree on the left, the block editor on the right, in a single layered card (matching the dial widget's "main details" pattern: bg-card, rounded-r-xl, drop shadow). The editor's max-width is tightened on the native surface for comfortable line length. See Canvases and widgets → native apps vs canvases.

The legacy pagesWidget / PageTreeWidget / PageEditorWidget node types are still registered, so any canvas you've already populated with a Pages widget keeps rendering the same combined workspace in its large variant under React.lazy(PageWorkspace).

When PageWorkspace mounts inside a canvas widget, the tree rows and editor wrapper opt out of React Flow gestures (nodrag, no-pan, plus onPointerDown stopPropagation) so dragging a page row reorders the tree instead of dragging the widget node, and clicking into the editor lets you range-select text (select-text) with an I-beam cursor (cursor-text) rather than panning the canvas. The native surface skips that ceremony — there's no React Flow board to fight.

Layout

PageWorkspace is composed of:

  • PageTreeSidebar — memoized tree with a data-table-style search row, Add page and Add folder icon buttons, and a resizable edge (width persists per widget instance via localStorage).
  • PageEditor — the block editor, with per-block React.memo so edits in one block don't re-render the whole document.
  • BlockRenderer — single read-only renderer used everywhere (kills the two drifted copies that lived in the old widget files).

The page tree

Pages are nested via parent_id. The sidebar supports:

  • Search across page titles
  • Drag-and-drop reorder — before, after, or nest-as-child (drop indicators show as a purple line). Empty folders and the empty area below the root list are explicit drop targets so the first child works the same as a re-parent. Position is stored in pages.sort_order (double precision) and updated with midpoint sparse indexing — a drop writes one row with the midpoint between its new neighbors, so the typical reorder is 1 API write regardless of list size. A renumber fallback kicks in when the gap collapses below precision.
  • Add page and Add folder icon buttons inline in the search row
  • Inline rename — double-click any row's title, or pick Rename from the more menu, to swap the title for an input that auto-focuses with the text selected. Enter or blur commits via updatePage(id, { title }); Escape cancels; empty / whitespace titles fall back to Untitled. While editing, row clicks (expand, select) and drag wiring are short-circuited so the input doesn't get hijacked. Folders especially needed this — clicking them only toggles expand, so there was no other way to rename them in place.
  • Lucide icons per page (via the shared IconPicker)
  • PageTreeDropdown in the breadcrumb for quick jumps without expanding the sidebar
  • Per-org collapsed-folder state persisted in localStorage — default-expanded preserves the legacy UX

Folders as a distinct row type

Migration pages_folders.sql. Pages with is_folder = true are pure containers — no editor view, only folders can hold children. The DB enforces containment via the check_pages_parent_is_folder trigger and the parent_id FK is now ON DELETE CASCADE. Client-side guards mirror the same rule (cycle prevention blocks dragging a folder into its own descendant; drops onto a non-folder page are rejected with no DB write).

Deleting a folder pops a confirm dialog with the descendant count before cascading the wipe in a single round-trip.

The parent_id → children map is memoized so tree-render cost is O(N) per pages change rather than O(N²).

The block editor

Type / to open the slash command palette. The 17 block types include:

  • Text — paragraph, h1, h2, h3, bulleted, numbered, quote, callout, divider, code, image
  • Interactive — todo (checkbox), toggle (collapsible)
  • Layout — columns (multi-column container)
  • Embed — markdown (live preview), table (live data table), inline page link

Drag the grip handle to reorder blocks. Empty blocks show placeholder text only when focused — keeps the page visually clean. The page title renders at text-4xl with tight tracking and the editor has generous side margins and Notion-style vertical rhythm.

Page icons set from the IconPicker propagate live: the useIcon hook re-resolves whenever its name prop changes, so an icon update shows up immediately in the tree, the breadcrumb, and any open Pages widget rather than waiting for a remount.

Embedded data tables

/table opens a table picker (search across all your data tables). Pick one and a TableBlock is inserted. It renders the table with the same column-type-aware cells as the Data mode (select pills, status badges, email, date, multi-tag) and has a refresh button + change table button.

This is how you write a page that references live data without copy-pasting.

Planning canvas

A page can attach a planning canvas — a text-centric React Flow board with Text, Task, Group, and Link nodes plus the shared Condition and Note nodes. It renders as an inline preview above the page blocks; clicking opens the full-screen PlanningEditor.

Use it for project plans, research outlines, dependency maps — anything where the structure is more important than the prose.

Auto-save

Block edits update local state immediately and debounce-persist the full blocks JSONB to Supabase after 800 ms. You see "Saving..." → "Saved" indicators in the page header.

Where to go next

On this page