Frontend Architecture (apps/web)
Next.js 14 (App Router) + React 18 + Tiptap + dnd-kit + TanStack Query + Zustand. Sibling docs: system · realtime · database · security.
The frontend is a presentation + local-state layer only. It never authorizes: every mutation is re-checked server-side, and the UI merely reflects the result.
1. Directory Structure
Per ARCHITECTURE.md §2:
apps/web
├── app/ # Next.js App Router
│ ├── (auth)/ # route group: login, register, reset, verify
│ │ ├── login/page.tsx
│ │ ├── register/page.tsx
│ │ ├── reset/[token]/page.tsx
│ │ └── verify/[token]/page.tsx
│ ├── (app)/[workspace]/ # authed workspace shell (sidebar + top bar + content)
│ │ ├── layout.tsx # shell: sidebar, top bar, command palette, toasts
│ │ ├── page.tsx # workspace home
│ │ ├── inbox/page.tsx # notifications (in-app)
│ │ ├── search/page.tsx # global search results
│ │ ├── favorites/page.tsx
│ │ ├── trash/page.tsx
│ │ ├── settings/ # workspace / members / security / billing / integrations
│ │ ├── teamspaces/[team]/ # teamspace subtree
│ │ └── p/[pageId]/ # the page canvas (editor or database home)
│ │ ├── page.tsx # resolves page → editor | database | record
│ │ └── ... # [pageId] also serves /p/[pageId]/[childPageId] nesting
│ ├── share/[token]/ # public shared pages (no auth, rate-limited)
│ ├── forms/[slug]/ # public forms (no auth)
│ └── api/ # BFF route handlers (server components / edge)
│ ├── auth/refresh/route.ts # cookie → new access token (keeps JWTs out of JS bundle paths)
│ ├── files/[fileId]/route.ts # stream/signed-URL proxy for file blocks
│ └── preview/[kind]/[fileId]/route.ts # image/PDF preview proxy
├── components/ # app-level composites
│ ├── topbar/ # workspace switcher, search button, AI button, avatar
│ ├── command-palette/ # Cmd/Ctrl+K (see §4)
│ ├── dialogs/ # share dialog, settings dialogs, delete confirms
│ └── notifications/ # toasts, inbox list
├── editor/ # block editor wiring (apps/web side)
│ ├── EditorCanvas.tsx # mounts @factory/editor on a page
│ ├── slash/ # slash command menu (searchable, dynamic /database)
│ ├── context-menus/ # block + page right-click menus
│ ├── dnd/ # dnd-kit sensors/containers (blocks, columns)
│ └── ai/ # "Ask AI" selection panel, rewrite popover
├── database/ # view renderers (see §6)
│ ├── DatabaseView.tsx # router: view type → renderer
│ ├── views/ # table, list, board, gallery, calendar, timeline,
│ │ # chart, dashboard, form renderers
│ ├── cells/ # property cell renderers per PropertyType (22)
│ ├── filter-builder/ # nested AND/OR/NOT UI → packages/database-engine tree
│ └── record-page/ # record's page (properties sidebar + blocks)
├── workspace/ # sidebar, page tree, teamspaces, settings UI
├── collaboration/ # presence avatars, cursors, Yjs bootstrap, offline badge
├── search/ # search page, suggestions, filter chips
├── settings/ # workspace/user/billing/security settings screens
├── hooks/ # usePage, useBlocks, useDatabase, useRealtime, useOffline,
│ # useCommandPalette, usePresence
├── stores/ # Zustand stores (see §5)
├── lib/ # api client (fetch + TanStack Query), ws client,
│ # formatters, date utils
└── types/ # frontend-only types (wire DTOs come from @factory/shared)
Dependency rule: app/ → components/ | editor/ | database/ | hooks/ →
packages/*. The editor/ and database/ directories never import from app/ (no
route awareness); they receive data through props and hooks, keeping them usable from
the command palette, dialogs, or public share pages.
2. Routing Map
2.1 Route table
| Path | Group | Auth | Purpose |
|---|---|---|---|
/ |
— | public | Redirect to last workspace or onboarding (create workspace) |
/login, /register |
(auth) |
public | Auth forms (React Hook Form + Zod) |
/reset/[token], /verify/[token] |
(auth) |
public | Single-use token flows |
/sso/callback |
(auth) |
public | SAML/OIDC assertion consumer → exchange for session |
/[workspace] |
(app) |
member | Workspace home (pinned/frequent pages) |
/[workspace]/inbox |
(app) |
member | In-app notifications list |
/[workspace]/search?q= |
(app) |
member | Global search (full-text + hybrid toggle) |
/[workspace]/favorites |
(app) |
member | Favorite pages (fractional-position list) |
/[workspace]/trash |
(app) |
admin+ | Trashed pages, restore/purge |
/[workspace]/teamspaces/[team] |
(app) |
member | Teamspace root page |
/[workspace]/p/[pageId] |
(app) |
per-page | Page canvas — a page, database home, or record page |
/[workspace]/settings/* |
(app) |
admin+ | profile, members, security, billing, integrations |
/share/[token] |
public | none (share-scoped) | Public shared page; respects allowComments, password, expiry |
/forms/[slug] |
public | none | Public form (see Form.status === PUBLISHED) |
2.2 The page canvas (/[workspace]/p/[pageId])
One route serves three shapes, resolved by usePage(pageId):
PageDto.kind:
• "page" → EditorCanvas (Tiptap block tree)
• "database" → DatabaseView (database home: view tabs + property rows + records)
• "record" → RecordPage (properties panel on the left + the record page's blocks on the right)
- URL nesting for child pages:
/[workspace]/p/[pageId]renderschild_pageblocks inline (achild_pageblock is a link to/p/[childId]), and deep links open the child in the same canvas. - The layout (
(app)/[workspace]/layout.tsx) renders the shell once; the canvas is the only part that changes per navigation, so Tiptap instances and Yjs providers are kept alive per page in a small LRU (default 3) to make back/forward instant.
2.3 Server vs client rendering
- Route handlers in
app/api(BFF): refresh-token rotation (server-to-server, keeps the access JWT in a non-HttpOnly-readable place for the editor while the refresh token stays HttpOnly), file/preview proxying (streams from signed URLs, so object storage keys never reach the client). - Server Components: the
(app)layout prefetches the workspace member + page metadata (RSC fetch against the API with the session cookie) to make first paint fast; the editor itself is always a Client Component (Tiptap requires the DOM). - Streaming: page canvas SSRs the shell + metadata; blocks hydrate client-side (or from the Yjs doc if the page is open in realtime).
2.4 Command palette (Cmd/Ctrl+K)
A single headless component (components/command-palette, no third-party fuzzy lib —
custom Levenshtein + subsequence scoring) with a flat command registry:
| Group | Commands (examples) |
|---|---|
| Navigation | Go to page (fuzzy over recent + all pages via search API), Open settings, Switch workspace |
| Create | New page, New database (table/board/calendar), New team, New form |
| Actions | Invite user, Toggle theme (light/dark/system), Export current page (md/html/pdf), Import, Ask AI… |
| Search | "Search: …" (falls through to POST /v1/search), recent searches |
Behavior: single overlay, focus-trapped, role="dialog", arrow keys + Tab between
groups, Enter executes, Esc closes. Every command is also reachable from a menu —
the palette is a shortcut, never the only path (accessibility requirement, §10).
3. Block Editor Integration
The editor is implemented in packages/editor (Tiptap extensions, slash menu,
context menus, DnD, fractional-position engine); apps/web/editor/ is the wiring layer
that mounts it on a page, connects it to stores, and hosts the AI selection panel.
3.1 Canonical model
The editor document is a block tree serialized to canonical Block JSON — never raw
HTML. One Tiptap node per block, each carrying attrs:
interface BlockNodeAttrs {
id: string; // "blk_…" cuid
position: string; // fractional, from the positions engine
content?: unknown; // type-specific (mirrors Block.content JSONB)
properties?: unknown; // colors, alignment, widths (mirrors Block.properties JSONB)
syncedSourceId?: string;
}
3.2 BlockType ↔ Tiptap node mapping
BlockType (schema enum) |
Tiptap node (packages/editor/src/nodes/*) |
Notes |
|---|---|---|
PARAGRAPH |
BlockNode (paragraph) |
base text node |
HEADING_1..3 |
BlockNode (heading 1–3) |
single node type, level attr |
QUOTE |
BlockNode (quote) |
styled, indented |
CALLOUT |
CalloutNode |
icon + background color in properties |
BULLETED_LIST |
ListBlockNode |
ProseMirror list item; list blocks nest by parentId |
NUMBERED_LIST |
ListBlockNode (ordered: true) |
numbering computed per sibling run |
TODO |
TodoBlockNode |
checkbox in properties.checked; drag handle |
TOGGLE |
ToggleBlockNode |
collapsible child container (children = blocks with parentId) |
IMAGE |
ImageBlockNode |
content: {fileId, width, align, caption}; lazy load, loading="lazy" |
VIDEO / AUDIO |
MediaBlockNode |
content: {fileId, kind}; native player or preview |
FILE |
FileBlockNode |
card UI: name, size, icon, download button (signed URL via BFF) |
BOOKMARK |
BookmarkBlockNode |
content: {url, title}; fetches og metadata on create |
EMBED |
EmbedBlockNode |
content: {url, type} (Loom, Figma, Twitter, YouTube, …) |
CODE |
CodeBlockNode |
content: {code, language}; Shiki highlight (Web Worker, off main thread) |
EQUATION |
EquationBlockNode |
content: {latex}; KaTeX (server-side render for SSR, client hydration) |
COLUMN |
ColumnNode |
child container; width in properties |
COLUMN_LIST |
ColumnListNode |
container of COLUMN nodes; flex/grid layout |
DIVIDER |
DividerNode |
atomic, no content |
SPACER |
SpacerNode |
properties.height px |
DATABASE |
DatabaseBlockNode |
embeds a full database view inline (see §6); content: {databaseId, viewId} |
LINKED_DATABASE |
LinkedDatabaseBlockNode |
renders another database's view filtered/linked |
TABLE |
TableBlockNode |
static table (distinct from database tables); content: {rows, cols} |
CHART |
ChartBlockNode |
renders charts row config (bar/line/area/pie/donut/number) |
FORM |
FormBlockNode |
embeds a published form preview |
SYNCED_BLOCK |
SyncedBlockNode |
read-only mirror of syncedSourceId's content (live-fetched) |
CHILD_PAGE |
ChildPageBlockNode |
link row → /p/[childId]; content: {pageId, icon, title} |
CHILD_DATABASE |
ChildDatabaseBlockNode |
link row → a database embedded elsewhere |
All block nodes are registered through one factory (packages/editor/src/editor.ts,
createFactoryEditor({ pageId, blocks, realtime?, readonly? })) so the same factory is
used by the page canvas, record pages, public share pages (readonly), and the slash
menu previews.
3.3 Extensions list
| Extension | Purpose |
|---|---|
@tiptap/core + StarterKit subset |
base document schema (we register our nodes, not StarterKit's paragraphs — one source of truth) |
@factory/editor block nodes (table §3.2) |
the block vocabulary |
@tiptap/extension-placeholder |
empty-block "Type '/' for commands" placeholder |
@tiptap/extension-character-count |
per-page stats (top bar) |
@tiptap/extension-dropcursor / Gapcursor |
atomic-block drop targets, gap between blocks |
y-prosemirror (@tiptap/extension-collaboration) |
CRDT binding when realtime is active; disabled in readonly/share contexts |
@factory/collaboration awareness adapter |
presence + cursor payload (see realtime, §5) |
Custom SlashMenu (plugin) |
/ detection, searchable command list, dynamic /database creation |
Custom ContextMenus |
right-click → block menu / page menu (turn into, duplicate, move to, comment, Ask AI, colors…) |
Custom MentionExtension |
@user/@team/@page/@database popup, stored as mention marks with id + label |
Custom CommentAnchorExtension |
text-selection comments (anchor/offset stored in comments.selection) |
dnd-kit integration (custom plugin) |
block drag: handle (⋮⋮), multi-select drag, drop indicators, auto-scroll, cross-column and cross-page drops (§3.5) |
Custom FractionalPositionEngine (packages/editor/src/positions) |
computes position strings on every insert/move (midpoint algorithm, re-balance on overflow) |
3.4 How the Yjs codec bridges (offline + realtime)
The Yjs document shape (packages/collaboration/src/doc-model.ts):
Y.Doc
├── yXml: Y.XmlFragment # ProseMirror document (via y-prosemirror) —
│ # the editable tree, one item per block node
├── yMeta: Y.Map<string, ...> # per-block side data NOT in the PM doc:
│ # position (fractional), id, content, properties
│ # (so reordering and non-text edits are CRDT-merged)
└── yAwareness # presence (ephemeral, not persisted)
Bridge rules (implemented in packages/collaboration/src/codec.ts, round-trip tested):
- Encode (Yjs → Block tree): walk
yXml; for each block item readid/typefrom the item attrs,content/propertiesfromyMeta.get(id), andparentIdfrom the tree structure; sibling order is taken frompositionstrings, not array order — so a persisted snapshot and the CRDT always agree on ordering. - Decode (Block tree → Yjs): build
yXmlitems inpositionorder; seedyMetawithcontent/properties/position. Hydration happens server-side (realtime, §4); the client hydrates the same way offline from its IndexedDB snapshot. - Local-first: every keystroke/drag applies to the local
Y.Docfirst (< 16 ms), the TanStack Query cache is invalidated lazily, and the sync engine ships the update (online: to the socket; offline: queued in IndexedDB). - No HTML in the wire. Copy/paste into the app parses external HTML/Markdown via the importer into block ops; copy out serializes the selection to Markdown/HTML at the boundary only.
3.5 Drag & drop (dnd-kit)
One DndContext per canvas with a multi-container strategy:
- Containers: each top-level sibling run, each
COLUMN, eachTOGGLEchild list, and (for kanban) each board group — registered as dnd-kit containers. - Sensors:
PointerSensor(with 4 px activation distance so clicks aren't drags),KeyboardSensor(arrow keys +Enter/Escape— the non-drag accessibility path),TouchSensor(long-press 150 ms on mobile). - Drop resolution: a custom
collisionDetectioncomputes the target container + index from pointer position; the resultingpositionis computed by the fractional engine from the two neighbors (positions.between(before, after)); the move is a single-block op applied to the Yjs doc → synced + persisted as onePATCH /v1/blocks/:id { position }. - Cross-page moves: dropping a block on a page in the sidebar (or a "Move to" menu item) serializes the block subtree, POSTs to the destination page, and deletes from the source — both ops in one Yjs transaction so collaborators see an atomic move.
- Multi-block drag: selection = range of siblings; the whole range moves with a new
base position, and interior
positionvalues are re-keyed under the new base (prefix shift), still O(range).
4. State Management
4.1 The split
| Layer | Tool | Owns |
|---|---|---|
| Server state | TanStack Query (@tanstack/react-query) |
everything fetched from the API: pages, blocks (initial), databases, records + property values, search, notifications, files, automations, AI conversations, audit. Mutations via useMutation with optimistic updates where UX demands (e.g. comment resolve) and cache invalidation wired to domain-event-style cache keys. |
| Client-only state | Zustand (vanilla + react bindings, devtools enabled in dev) |
editor caret/selection, UI panels, presence, offline queue, DnD transient state, command palette state |
| Forms | React Hook Form + Zod | auth forms, settings forms, form-builder, filter builder (Zod schema from @factory/shared validates both client and server) |
| URL state | useSearchParams / route params |
workspace slug, page id, search q, view id (?view=), filters (?filter=) — anything shareable via link lives in the URL, never in a store |
4.2 TanStack Query conventions
- Query client created once in the app root;
staleTime: 30 sdefault,retry: 2(no retry on 4xx/409),gcTime: 10 min. - Key factory (
lib/query-keys.ts):
const keys = {
pages: (wid: string) => ['workspaces', wid, 'pages'] as const,
page: (wid: string, id: string) => ['workspaces', wid, 'pages', id] as const,
tree: (wid: string) => ['workspaces', wid, 'pages', 'tree'] as const,
database: (wid: string, id: string) => ['workspaces', wid, 'databases', id] as const,
records: (wid: string, dbId: string, viewId: string, page: number) =>
['workspaces', wid, 'databases', dbId, 'records', viewId, page] as const,
search: (query: SearchQuery) => ['search', query] as const,
comments: (pageId: string) => ['comments', pageId] as const,
files: (fileId: string) => ['files', fileId] as const,
ai: (convId?: string) => ['ai', convId ?? 'conversations'] as const,
};
- Mutations invalidate by key family (
keys.pages(wid)) — not the whole cache. - Realtime edits arrive via the Yjs provider, not the Query cache: the canvas reads the
Yjs doc directly, and
useQuerydata is only the initial hydration source.
4.3 Zustand stores (names and shape)
| Store | Purpose | Key fields |
|---|---|---|
useEditorStateStore |
per-page editor session (one instance per open page, keyed by pageId) | caret: {blockId, offset}, selection: {fromBlockId, toBlockId, fromOffset, toOffset}, activeBlockId, slashMenu: {open, anchor, query}, readOnly |
useUiStateStore |
app chrome | sidebarOpen, sidebarWidth, commandPaletteOpen, activePanel (comments | properties | share | null), toasts: Toast[], theme: 'light'|'dark'|'system' |
usePresenceStore |
per-room presence | peers: Map<userId, PresencePeer>, cursors: Map<userId, CursorPayload>, self: {userId, color, name} — populated from Yjs awareness events |
useOfflineQueueStore |
offline sync state | status: 'online'|'offline'|'syncing', pendingCount, lastSyncAt — mirrors the IndexedDB queue in @factory/collaboration |
useDndStore |
transient DnD state | draggingIds: string[], dragOverContainerId, `dropIndicator: {containerId, index} |
usePaletteStore |
command palette | open, query, activeIndex, groups |
useRealtimeStore |
socket lifecycle | wsStatus: 'closed'|'connecting'|'open', activeRooms: string[], reconnectAttempt |
Persistence: only useUiStateStore (theme, sidebar) uses persist (localStorage).
Presence/offline/DnD are never persisted (presence is ephemeral by design; the
offline queue lives in IndexedDB, not localStorage — see §7).
5. Data Fetching Hooks
| Hook | Returns | Source |
|---|---|---|
usePage(wid, pageId) |
PageDto (+ kind discriminator) |
GET /v1/pages/:id |
usePageTree(wid) |
flat page tree (positions, titles, icons) for sidebar | GET /v1/workspaces/:wid/pages/tree (cascades lazily; children loaded per expanded node) |
useBlocks(pageId) |
initial block list (only when realtime is off) | GET /v1/pages/:id/blocks |
useDatabase(wid, dbId) |
database + properties + views | GET /v1/databases/:id |
useRecords(wid, dbId, viewId, cursor) |
page of records + values (server-side filter/sort/group/paginate) | POST /v1/databases/:id/query |
useRecordPage(wid, recordId) |
record + its page + property values | GET /v1/databases/:id/records/:rid |
useSearch(query) |
results + facets | POST /v1/search |
useComments(pageId) |
threads for the page | GET /v1/pages/:id/comments |
useRealtime(wid, resourceId) |
socket handle + doc handle (from @factory/collaboration) |
WebSocket /realtime |
useOffline() |
{status, pendingCount, flush()} |
useOfflineQueueStore + queue events |
6. Database View Renderer Architecture
database/DatabaseView.tsx is a thin router: it receives DatabaseDto (properties +
views), reads ?view= from the URL, and mounts the matching renderer. All renderers
share the query pipeline:
view config (filter tree, sort[], group[], pageSize, visible properties)
│ (Zod-validated view settings from @factory/shared)
▼
POST /v1/databases/:id/query { viewId | adHoc, page: cursor }
│ server: packages/database-engine applies filter → sort → group → paginate
│ + eager property values (EAV pivot) + relation counts (for relation cells)
▼
RecordsPageDto { records: RecordWithValues[], cursor, total, groups?: GroupMeta[] }
▼
renderer (virtualized list/canvas) → row/cell components → cell renderers per PropertyType
View (ViewType) |
Renderer (database/views/*) |
Virtualization |
|---|---|---|
TABLE |
TableView — grid: sticky header, column resize (dnd-kit handles), row density, inline cell editing (double-click → property editor popover) |
@tanstack/react-virtual rows (overscan 10) + column virtualization for > 50 visible columns |
LIST |
ListView — minimal cards, 1–2 visible properties |
row virtualization |
BOARD |
BoardView — groups become columns (kanban); cards show cover + first N visible properties |
column virtualization (horizontal) + per-column card virtualization; row drag between groups = relation/property update op |
GALLERY |
GalleryView — card grid with cover images |
grid virtualization |
CALENDAR |
CalendarView — month/week; records positioned by date property |
month-level windowing (render visible month + neighbors only) |
TIMELINE |
TimelineView — Gantt-like rows by date range |
row virtualization + time-axis zoom |
CHART |
ChartView — renders charts config (bar/line/area/pie/donut/number) |
data is server-aggregated (query returns aggregate buckets, not rows) |
DASHBOARD |
DashboardView — widget grid from DashboardWidget.layout; each widget embeds a table/board/chart/number/calendar/timeline/text/linked-database source; drag/resize with dnd-kit (grid mode) |
each embedded widget is virtualized internally |
FORM |
FormView — builder (authed) or public submission page (/forms/[slug]) |
n/a |
Cell renderers (database/cells/*, one per PropertyType enum — 22 total):
TitleCell, TextCell, RichTextCell, NumberCell, SelectCell, MultiSelectCell,
StatusCell (color pill + progress for PERCENT), DateCell, DateRangeCell,
PersonCell (avatar + name, click → profile), EmailCell, PhoneCell, UrlCell,
CheckboxCell, FilesCell, CreatedTimeCell, CreatedByCell, LastEditedTimeCell,
LastEditedByCell, RelationCell (chips, click → open target record, + to link —
mutates RelationLink), RollupCell (read-only computed), FormulaCell (read-only
computed), ButtonCell (click executes the button action server-side:
POST /v1/databases/:id/buttons/:bid/execute), UniqueIdCell (read-only rec_…).
Interaction model: inline cell edits are optimistic — the local row state updates
immediately (Zustand transient map cellOverrides), the mutation is issued, and on
failure the override is rolled back with a toast. Bulk ops (board drag, filter-then-
update) batch into one POST /v1/databases/:id/records/batch.
Rollups/formulas: the server precomputes rollup and formula values in the query
response (event-driven recompute in the worker keeps them fresh — see
system, ADR / database §7); the client
renders them read-only and never runs the formula engine locally for display (the
packages/database-engine evaluator exists for live-preview of formula edits in the
property editor only, with the same safe AST — no eval, ever).
7. Offline Sync Flow
Local-first editing works with or without a server connection, built on
@factory/collaboration:
┌────────────────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ Tiptap/Yjs local doc (in memory) │
│ │ every op applies locally first (< 16 ms) │
│ ▼ │
│ IndexedDB ("factory-collab") │
│ ├── y-docs/{pageId} → Y.Doc state vectors + update log │
│ ├── queue/{pageId} → outbound updates not yet acknowledged │
│ └── meta/ → lastServerUpdate, lastAck │
│ │ │
│ │ online: ws.send(update) ──────────────▶ apps/realtime │
│ │ offline: queue.append(update) (persisted immediately) │
│ ▼ │
│ SyncEngine │
│ • navigator.onLine + ws heartbeat drive status transitions │
│ • on reconnect: send full pending log (Yjs merge is idempotent) │
│ • apply server-ack (state vector) → truncate queue │
│ • non-CRDT ops (record property edits, comments, view config) │
│ are REST mutations kept in the same queue with idempotency keys │
│ (client-generated UUIDs; server dedupes on the key) │
└────────────────────────────────────────────────────────────────────────┘
- Conflicts: CRDTs merge block text/structure with no user action. Non-CRDT ops use
last-writer-wins per field with server
versionchecks (409 → refetch + merge in the UI, never silent overwrite). - Offline indicator: top bar badge (
useOfflineQueueStore): "Offline — 14 changes queued", "Syncing…", "All changes saved". - What does NOT work offline: search, AI, public share pages, and other workspaces' data. The app degrades gracefully (search box shows "available when online").
8. Virtualization Strategy
All large collections use @tanstack/react-virtual (or its grid mode); nothing renders
more than the viewport + overscan.
| Surface | Unit | Strategy |
|---|---|---|
| Sidebar page tree | page node | render expanded nodes only; children fetched lazily per expansion (one API call per level); fractional-position order from the API |
| Page canvas (blocks) | block row | the Yjs doc holds the whole tree in memory (needed for CRDT), but the DOM renders virtualized rows; a 100k-block page keeps ~60 rows mounted; heavy block types (code highlight, KaTeX, charts) render in Web Workers or lazy-IntersectionObserver subtrees |
| Table view | row (100k+) | row virtualization, overscan: 10; sticky first column + header; column virtualization beyond 50 visible properties |
| Board view | columns + cards | horizontal column virtualization; cards per column virtualized; covers lazy-loaded |
| Gallery/timeline | cards / rows | grid / row virtualization |
| Search results | result row | virtualized list + cursor pagination ("Load more" appends, keyed by cursor) |
| Comments panel | thread | windowed list; replies collapsed until expanded |
| Inbox/notifications | row | virtualized + cursor pagination |
| Dashboard | widgets | only visible widgets hydrated (each widget self-virtualizes its data) |
Rules: (1) virtualization is a renderer concern — data hooks return full pages from
the API (cursor-paginated), the virtualizer only controls DOM; (2) measured targets:
100k-record database interaction < 50 ms, reorder < 16 ms (Phase 4/5 exit criteria);
(3) requestAnimationFrame-coalesced scroll updates, no layout thrash (transforms only).
9. Theming
Design tokens live in packages/ui as CSS custom properties; no hard-coded colors
anywhere (lint rule: no-restricted-syntax against hex literals in apps/web).
| Token family | Examples |
|---|---|
| Color | --color-bg, --color-bg-elevated, --color-text, --color-text-muted, --color-border, --color-accent, --color-danger, --color-success, --color-warning |
| Block/content | --color-block-quote-bg, --color-callout-{info,warning,success,danger}-bg, --color-todo-check |
| Database | --color-property-{status,select}-* (user-configurable palettes map onto token slots) |
| Spacing | --space-1 … --space-8 (4 px base) |
| Radius | --radius-sm/md/lg/full |
| Typography | --font-sans, --font-mono, --text-xs … --text-2xl (fluid sizes via clamp()) |
| Motion | --duration-fast:120ms, --duration-slow:240ms, --ease-out; all reduced to 0 under prefers-reduced-motion |
| Shadow | --shadow-sm/md/lifted (elevation for popovers, dialogs, drag ghosts) |
- Modes:
light,dark,system(per-userUser.theme+ per-device override inuseUiStateStore); applied viadata-themeon<html>;systemfollowsprefers-color-schemewith a livematchMedialistener. - No flash of wrong theme: a 6-line inline script in the
<head>setsdata-themefrom localStorage before first paint. - Brand overrides: workspaces can set an accent color (settings) → maps to
--color-accentvia HSL channels, so dark mode contrast is derived, not re-picked. - High-contrast: a
data-contrast="high"variant re-maps the color tokens (WCAG 2.2 AA contrast checked in CI with a token snapshot test).
10. Accessibility Approach (WCAG 2.2 AA)
| Area | Approach |
|---|---|
| Keyboard | every mouse interaction has a keyboard path: slash menu, context menus, palette (native key handling), DnD via KeyboardSensor (space to lift, arrows to move, space to drop, esc to cancel), column resize via handle focus + arrows |
| Non-drag alternatives | "Move to" in every block context menu; sidebar pages reorderable via arrow keys; board cards have a "Move to group" menu item |
| ARIA semantics | editor is a ProseMirror contenteditable with role="textbox" + aria-multiline; block handles are role="button" with aria-label="Move paragraph block"; slash/context menus are role="menu" + roving tabindex; drag ghosts are announced via a live region (role="status", polite) |
| Focus management | focus-trapped dialogs (Radix primitives in packages/ui); focus returns to the trigger on close; canvas navigation restores the block under the caret on reload (aria-labelledby on the active block heading) |
| Screen readers | live regions for: save status ("Saved" / "Offline — queued"), presence changes ("Ana joined"), comment resolution; every icon-only button has aria-label; decorative icons aria-hidden |
| Vision | contrast AA (token test in CI), focus visible (2 px --color-accent outline, never removed), :focus-visible only, text scaling to 200 % without overflow (fluid type + rem everywhere) |
| Motion | prefers-reduced-motion zeroes animations and disables auto-scroll inertia during drag |
| Forms | React Hook Form + Zod errors rendered as aria-live="assertive" text linked via aria-describedby; autocomplete attributes on auth forms |
| Color independence | status is never conveyed by color alone (icons/labels on status pills, todo state) |
E2E coverage: Playwright + @axe-core/playwright audit on the page canvas, table view,
and share page in the CI contract (Phase 4/5 Definition of Done).
11. Performance Budgets (frontend)
| Metric | Budget | Mechanism |
|---|---|---|
| First meaningful paint (page) | < 2 s | SSR shell + metadata, RSC prefetch, fonts font-display: swap + self-hosted, editor chunk code-split (loaded after metadata renders) |
| Bundle | editor core < 250 KB gzip | Tiptap extensions registered per-node; code highlight (Shiki) lazy; KaTeX lazy; dnd-kit tree-shaken |
| Editor interaction | < 50 ms | ops apply to Yjs locally; DOM patch via ProseMirror; no React re-render per keystroke (editor state lives in Zustand, not React state) |
| Local block move | < 16 ms | single position string recompute + one Yjs op + one virtualizer re-layout |
| Search response (client perceived) | < 500 ms | debounce 250 ms, abortable fetch, cached suggestions |
| Memory | 100k-block page < 500 MB | Yjs doc in memory is the cost; blocks' heavy content (code text > 50 KB) stored in content but rendered lazily |