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-blockReact.memoso 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 is1API 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 toUntitled. 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) PageTreeDropdownin 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.