# Tessera specification This document is the contract for everyone building Tessera. It explains what we build, how the pieces fit, and exactly how your part plugs in. The code in `packages/core` is the source of truth: if this document and the code disagree, the code wins. File a *Contract change request* in your HANDOFF file (see [Merge plan](#12-merge-plan)) and keep going with a local workaround. Contents: [1. Vision](#1-vision) · [2. Scope](#2-scope) · [3. Architecture](#3-architecture) · [4. Data model](#4-data-model) · [5. Document schema](#5-document-schema) · [6. Runtime and interfaces](#6-runtime-and-interfaces) · [7. App shell](#7-app-shell) · [8. Design system](#8-design-system-packagesui) · [9. Ownership and conventions](#9-ownership-and-conventions) · [10. Performance budgets](#10-performance-budgets) · [11. Security](#11-security) · [12. Merge plan](#12-merge-plan) · [13. Stack and decisions](#13-stack-and-decisions) ## 1. Vision **Tessera is an open-source, local-first knowledge app: Notion's blocks and databases, Obsidian's links and graph, real-time collaboration and plugins, on your own device and your own server.** - **Local-first, literally.** The app works fully offline. Your device holds the real data; a server is optional and only syncs. - **Never lose data.** Every keystroke is persisted before anything else happens. Sync converges. Destructive actions can be undone or are confirmed. - **No lock-in.** Import from Notion and Obsidian in one click, export to plain markdown any time. - **Fast and calm.** Instant startup, no layout shift, quiet UI in the spirit of Linear and Notion. - **Safe to extend.** Plugins run sandboxed with explicit permissions. The bar: someone who tries it for five minutes wants to star it. ## 2. Scope ### v1 (0.1.0) scope | Area | What ships | Agent | |---|---|---| | Shell | Sidebar with page tree (drag and keyboard reorder and nest), favorites, trash, top bar with breadcrumbs, settings, onboarding, shortcuts overlay, light and dark themes, phone layout | 01 | | Editor | TipTap block editor on Yjs implementing the canonical schema: slash menu, markdown shortcuts, block handle and drag, tables, callouts, toggles, code, images, embeds, `[[links]]`, `#tags`, clipboard, collaboration cursors | 02 | | Storage and sync | IndexedDB persistence, multi-tab, Hocuspocus server with SQLite, auth and roles, presence, version history | 03 | | Databases | Query engine, table, board, calendar, gallery and list views, every property type, CSV import and export, inline databases | 04 | | Search and graph | MiniSearch index in a worker, command palette, backlinks and unlinked mentions, global and local graph | 05 | | Plugins | Sandboxed iframe host, typed RPC, permissions, SDK, registry, five example plugins, scaffolder | 06 | | Desktop and self-hosting | Tauri 2 app with SQLite, workspaces as folders, quick capture, updater; Dockerfile, compose, deploy guides | 07 | | Markdown, import and export | remark codec with Obsidian syntax, Notion, Obsidian and markdown importers, markdown zip, JSON backup, HTML and PDF export | 08 | | Quality | CI workflows, seeded workspace generator, journeys, benchmarks, bundle budgets, axe checks | 09 | | Docs and launch | README, VitePress docs, brand, demo workspace, launch kit | 10 | ### Non-goals for v1 - Native mobile apps. The web app works at phone width; there are no iOS or Android builds. - End-to-end encryption. The server stores Yjs updates as they are; TLS protects them in transit. - Per-page permissions or public sharing. Roles (owner, editor, viewer) apply to a whole workspace. - Comments, @-mentions of people, notifications, reminders. - AI features. - Custom node or mark types from plugins. Plugins extend the document only through `embed` blocks. - Rewinding CRDT history. "Restore version" writes the old content as a new edit. - Federation between servers. - Importers beyond Notion, Obsidian and markdown folders (Logseq, Bear, Evernote are stretch goals). ## 3. Architecture ```mermaid flowchart TB subgraph clients["Clients"] web["Web app (apps/web)
React shell + feature modules"] desktop["Desktop app (apps/desktop)
Tauri 2 window running apps/web"] end subgraph core["@tessera/core runtime (inside every client)"] runtime["AppRuntime and WorkspaceSession
AppContext: events, commands, blocks,
contributions, settings, workspace API"] docs["DocManager
ref-counted Y.Docs (ws:, page:, db:)"] resolver["Service resolution
highest available priority wins"] end subgraph impls["Service implementations"] stubs["In-memory stubs, priority 0
(@tessera/core)"] browser["Browser, priority 50
IndexedDB stores, MiniSearch, link index,
remark codec, Hocuspocus provider"] tauri["Desktop, priority 100
SQLite + files through Tauri"] end server["Server (apps/server)
Hocuspocus + SQLite + Hono HTTP API"] web --> runtime desktop --> runtime runtime --> docs runtime --> resolver resolver --> stubs resolver --> browser resolver --> tauri docs -- "DocStore: load, storeUpdate, compact" --> resolver docs -- "SyncProvider: connect(docName, ydoc)" --> resolver browser -- "WebSocket: Yjs updates + awareness" --> server tauri -- "WebSocket" --> server web -- "HTTPS: auth, workspaces, invites, assets" --> server ``` **How an edit flows.** The editor changes the page's `Y.Doc`. The `DocManager` sees the update and calls `DocStore.storeUpdate` right away (retrying on failure), so the edit is durable locally. The `SyncProvider` sends it to the server, which persists it and fans it out to other clients. The runtime derives events (`doc.changed` for content, `page.*` for metadata), and the search and link indexes update from them. Remote edits arrive the same way in reverse and fire the same events with `local: false`. **Layers.** - `packages/core`: contracts, data model helpers, schema, stubs and the runtime. No DOM needed (React bindings are in `@tessera/core/react`). - `packages/ui`: design tokens, components and `t()`. - Feature packages (`editor`, `sync`, `db-views`, `search`, `plugins`, `plugin-api`, `markdown`, `importers`, `testkit`): each depends only on `core`, `ui` and its own libraries. A package never imports another agent's package. The one allowed exception is within an agent: `importers` may use `markdown`, `plugins` may use `plugin-api`. - `apps/web`: the shell. It loads every feature through `apps/web/src/features//index.ts`. `apps/web` depends on every internal package, so a feature can import its own package without editing `apps/web/package.json`. - `apps/desktop`: the Tauri app wrapping `apps/web`. `apps/server`: the sync and API server, which also serves the built web app. ## 4. Data model All document state lives in Yjs. Everything else (UI state, caches) is derived. Other packages never touch raw `Y.Map`s of core docs: they use the typed helpers, which validate input, run in one transaction, and throw typed errors (`NotFoundError`, `ValidationError`, `InvalidOperationError`, all subclasses of `TesseraError` with a stable `code`). ### 4.1 Identifiers, time and order | Thing | Rule | Helpers | |---|---|---| | IDs (pages, databases, properties, options, views, workspaces, assets) | nanoid(21) from `newId()`. Accepted: `ID_PATTERN` (`[A-Za-z0-9_-]{1,64}`). | `newId`, `isValidId` | | Block IDs (`blockId` attribute) | Short URL-safe IDs, `BLOCK_ID_PATTERN`. | `newBlockId`, `isValidBlockId` | | Timestamps | Epoch milliseconds (`Date.now()`). | | | Order | Fractional index strings. Sort by `order`, then `id` as a tie-break. | `compareOrdered`, `sortOrdered`, `orderBetween`, `ordersBetween`, `orderForIndex`, `orderAfterAll`, `positionToIndex` | | Positions | `ListPosition = 'start' \| 'end' \| { index } \| { before: id } \| { after: id }` | used by pages, rows, properties, options, views | ### 4.2 Doc names | Doc | Name | Helper | |---|---|---| | Workspace | `ws:` | `workspaceDocName(id)` | | Page | `page:` | `pageDocName(id)` | | Database | `db:` (same ID as the database page) | `databaseDocName(id)` | `parseDocName(name)` returns `{ kind, id }`. Doc names are what `DocStore`, `SyncProvider` and the server see. ### 4.3 Workspace doc (`ws:`) | Key | Type | Holds | |---|---|---| | `pages` | `Y.Map>` | One nested map per page, so concurrent edits to different fields of the same page merge. | | `settings` | `Y.Map` | Workspace settings shared with collaborators (`ctx.settings.workspace`). | | `meta` | `Y.Map` | `schemaVersion` (`DATA_MODEL_VERSION`, currently 1) and `createdAt`, written once by `initWorkspaceDoc`. | | `deletedPages` | `Y.Map` | Every page deleted permanently (`deletePagePermanently`, `emptyTrash`), with the time. `permanentlyDeletedPageIds(ws)` reads it, plus the tombstones of keys removed from `pages` (deletions made before the record existed), minus pages that exist again. A page that is only missing on this device (not synced yet) is never in it, so any client may remove references to these IDs. | **`PageMeta`** (one entry of `pages`): | Field | Type | Notes | |---|---|---| | `id` | string | Also the ID of the page doc and, for databases, of the database doc. | | `kind` | `'page' \| 'database'` | `database` pages render a database; their child pages are its rows. | | `title` | string | Plain text, at most 2,000 characters, no line breaks. Empty means "Untitled": render `t()`'s "Untitled", never store it. | | `icon?` | string | One emoji (one grapheme). | | `cover?` | `PageCover` | `{ kind: 'preset' \| 'asset' \| 'url', value, positionY? }`. Presets come from `packages/ui` (`COVER_PRESETS`); `url` must be `https:`. | | `parentId` | string or null | null = top level. | | `order` | string | Fractional index among siblings. | | `createdAt`, `updatedAt` | number | `updatedAt` also moves on content edits (debounced, see 6.1). | | `createdBy?`, `updatedBy?` | string | User IDs. | | `trashedAt?`, `trashedBy?` | number, string | Set only on the page the user trashed. | | `favorite?` | boolean | Shown under Favorites. | Rules: - **Titles live in `PageMeta`,** so the sidebar, search, link autocomplete and breadcrumbs never load page docs. The page header edits `PageMeta.title`; `pageLink` nodes store only the target ID and render the current title, so renames propagate. - **Trash is implicit for descendants.** Trashing a page sets `trashedAt` on that page only; every descendant counts as trashed (`isTrashed`) and comes back with it. `trashPage` and `restorePage` return a `TrashChange` listing every affected page. Restoring a page whose parent is still in the trash moves it to the top level. - **Robust tree.** Pages whose parent is missing are shown at the top level. Parent cycles (possible after concurrent moves) are broken deterministically: the page with the smallest ID becomes a root. Every client computes the same tree. - **Database rows are pages.** A row is a page whose `parentId` is a database page. `rowId === pageId`. Its title is the database's title column. Rows never show in the sidebar tree; they cannot be moved with `movePage` (their order lives in the database doc). Create rows with `ctx.workspace.addDatabaseRow`. - **Page docs are created lazily**, the first time something writes to them. - **Permanent deletion** (`deletePagePermanently`, `emptyTrash`) removes the subtree from the workspace doc, deletes the page and database docs from the `DocStore`, and removes row entries from their database. Only trashed pages can be deleted permanently from the UI, always after a confirmation. Workspace helpers (pure functions over a `Y.Doc`, also bound to the session as `ctx.workspace`): `createPage`, `createPages` (many pages in one transaction with one page index: O(n) instead of n × O(n); importers use it), `renamePage`, `movePage`, `setIcon`, `setCover`, `setFavorite`, `touchPage`, `trashPage`, `restorePage`, `deletePagePermanently`, `emptyTrash`, `getPage`, `requirePage`, `listPages`, `getChildren`, `getAncestors`, `getDescendants`, `isPageTrashed`, `isDatabaseRow`, `listTrash`, `indexPages`, `observePages`. `createPageIndex(pages)` builds the queryable index (`tree`, `children`, `ancestors`, `descendants`, `favorites`, `trash`, `isTrashed`, `isRow`, `effectiveParentId`) that `usePages()` returns. ### 4.4 Page doc (`page:`) | Key | Type | Holds | |---|---|---| | `content` | `Y.XmlFragment` | The ProseMirror document in the canonical schema ([section 5](#5-document-schema)). Only the editor binds to it directly; everyone else uses `readDocJSON` and `writeDocJSON`. | | `props` | `Y.Map` | Page properties. Well-known keys: `tags` (string[], without `#`), `aliases` (string[], used for link resolution and unlinked mentions), `fullWidth` (boolean), `smallText` (boolean). Other keys are imported frontmatter, kept so exports can write it back. | Helpers: `getPageContent`, `getPageProps`, `getPageProp`, `setPageProp`, `setPageProps`, `observePageProps`. ### 4.5 Database doc (`db:`) | Key | Type | Holds | |---|---|---| | `schema` | `Y.Map` | Property definitions; select options in a nested `options` map. | | `views` | `Y.Map>` | View configurations, one nested map per view so fields merge. | | `rows` | `Y.Map` | `{ order, values: Y.Map, valuesUpdatedAt?, valuesUpdatedBy? }` | | `meta` | `Y.Map` | `schemaVersion`, `createdAt`, `rowTemplateId` (a page copied into new rows). | Row **values live in the database doc** so views can filter, sort and group without loading any row page. A row's body is an ordinary page doc. `resolveRows(listRows(db), pages)` joins rows with their `PageMeta` (title, icon, timestamps) and flags `trashed` rows (views hide them) and `missingPage` rows (metadata not synced yet); `getCellValue(row, property)` reads any column, including `title`, `createdTime` and `updatedTime`. **Property types** (`PROPERTY_TYPES`): | Type | Stored value | Notes | |---|---|---| | `title` | none (`PageMeta.title`) | Exactly one per database. | | `text` | string | Up to 100,000 characters. | | `number` | finite number | `NumberConfig`: `plain`, `percent` (stores ratios: 0.25 shows 25%), `currency` (ISO 4217), precision. | | `select` | option ID | Options: `{ id, name, color: TagColor, order }`. | | `multiSelect` | option IDs (unique, in pick order) | | | `date` | `DateValue` | `{ start, end?, includeTime?, timeZone? }`. Date-only values are `YYYY-MM-DD`; with time, ISO 8601 instants with an offset. `DateConfig` sets the display format. | | `checkbox` | boolean | Missing means unchecked. | | `url`, `email` | string | Render safely (`isSafeHref`). | | `relation` | page IDs | `RelationConfig`: `targetDatabaseId` (or null for any page), `backPropertyId` for two-way relations, `limit: 'one' \| 'many'`. | | `createdTime` | none (`PageMeta.createdAt`) | | | `updatedTime` | none (later of the page's `updatedAt` and the row's `valuesUpdatedAt`) | | | `formula` | none (computed) | `FormulaConfig`: `expression`, in the Notion-like language of `@tessera/db-views/query` (`prop("Name")`, `if()`, `dateAdd()`, …; no `eval`), evaluated per view query. | Changing a property's type keeps its type-specific config (switching select, then text, then select restores the options). Stored values that no longer validate after a type change are treated as empty and left in place, so switching back restores them. `propertyValueSchemas` and `validatePropertyValue` validate values with zod; `TAG_COLORS` names map to theme tokens (`bg-tag--bg`, `text-tag--fg`). Never store raw CSS colors. **Views** (`ViewConfig`): `id`, `name`, `type` (`table`, `board`, `calendar`, `gallery`, `list`), `order`, `filter` (a `FilterGroup`: nested `and`/`or` groups of conditions, operators per property type in `FILTER_OPERATORS_BY_TYPE`, relative dates in `RELATIVE_DATE_RANGES`), `sorts` (`SortRule[]`), `group` (`GroupConfig`, empty group key `EMPTY_GROUP_KEY`), `properties` (per-view visibility, width and order), `summaries` (`SUMMARY_KINDS`) and type-specific options (`TableOptions`, `BoardOptions`, `CalendarOptions`, `GalleryOptions`, `ListOptions`). These are types only: the query engine belongs to Agent 04 (`packages/db-views/src/query`). Database helpers (all transactional, validate before writing, tested): `initDatabaseDoc`, `getDatabaseMeta`, `setRowTemplate`, `listProperties`, `getProperty`, `getTitleProperty`, `addProperty`, `updateProperty`, `deleteProperty`, `moveProperty`, `findSelectOption`, `addSelectOption`, `updateSelectOption`, `deleteSelectOption`, `moveSelectOption`, `listRows`, `getRow`, `countRows`, `addRow`, `addRows` (many rows in one transaction, validated first), `checkRowValues`, `setRowValue`, `setRowValues`, `deleteRow`, `moveRow`, `resolveRows`, `getCellValue`, `listViews`, `getView`, `addView`, `updateView`, `deleteView`, `duplicateView`, `moveView`, `observeDatabase`. Deleting a property also removes it from every view's filter, sorts, grouping and property list. Create databases and rows through the session so the page and the database doc stay consistent: ```ts const { page, titlePropertyId, viewId } = await ctx.workspace.createDatabase({ title: t('db-views:readingList'), titlePropertyName: t('db-views:name'), viewName: t('db-views:table'), }); const row = await ctx.workspace.addDatabaseRow(page.id, { title: 'Dune', values: { [statusId]: optionId } }); // Many rows (CSV import, paste): one workspace and one database transaction, nothing left on error. const rows = await ctx.workspace.addDatabaseRows(page.id, csvRows, { after: row.id }); ``` ### 4.6 Settings | Store | Where | Synced | Use for | |---|---|---|---| | `ctx.settings.device` | `localStorage`, keys prefixed `tessera:device:` (`LocalStorageSettingsStore`) | No | Theme, sidebar width, per-device preferences | | `ctx.settings.workspace` | The workspace doc's `settings` map | Yes, with collaborators | Workspace-wide preferences | Values are JSON. Keys are `.` (`backlinks.showFooter`, `editor.spellcheck`). Well-known keys are in `SETTING_KEYS`: `shell.theme` (`light`, `dark`, `system`), `shell.language`, `shell.sidebarOpen`, `shell.sidebarWidth`, `user.id`, `user.name`, `user.color`. Both stores fire `settings.changed`; `useSetting(store, key, fallback)` reads and writes reactively. **The current user** is device-wide: `{ id, name, color }`. `id` is a nanoid created on first run and stored in `user.id`. The sync feature sets `user.id` to the account ID after signing in; the runtime reads it live, fires `user.changed` and updates awareness. Authorship fields (`createdBy`, `updatedBy`, `trashedBy`) use whatever `id` is current. ### 4.7 Versions `DATA_MODEL_VERSION` (1) versions the Y.Doc layout; `DOC_SCHEMA_VERSION` (1) versions the document schema. Both are stamped when docs are created. A breaking change bumps them and ships a migration in `packages/core`. Indexes persisted by Agent 05 must rebuild when either changes. ## 5. Document schema `packages/core/src/schema` defines the canonical ProseMirror schema (`tesseraSchema`, no DOM). The editor implements exactly this schema, the markdown codec produces exactly this schema, and the indexers parse exactly this schema. Its machine-readable description is exported as `SCHEMA_DESCRIPTION` and checked in as `packages/core/src/schema/schema-description.json`; the editor's conformance test compares TipTap's schema against it with `diffSchemaDescriptions`. ### 5.1 Nodes Every block that can be linked, colored or dragged has a `blockId` attribute (null until someone needs a stable reference; the editor assigns IDs to new blocks). | Node | Group | Content | Attributes (default) | |---|---|---|---| | `doc` | | `block+` | | | `paragraph` | block | `inline*` | `blockId` (null), `color` (null) | | `heading` | block | `inline*` | `level` (1; 1–3), `blockId`, `color` | | `blockquote` | block | `block+` | `blockId`, `color` | | `callout` | block | `block+` | `emoji` ('💡'), `tone` ('default'; `CALLOUT_TONES`: default, info, success, warning, danger), `blockId` | | `codeBlock` | block | `text*`, no marks | `language` (null; `[a-z0-9_+#.-]{1,64}`), `blockId` | | `horizontalRule` | block | leaf | | | `image` | block | atom | `assetId`, `src`, `alt`, `title`, `width` (all null), `blockId`. Needs `assetId` or a safe `src`. | | `bulletList` | block, list | `listItem+` | | | `orderedList` | block, list | `listItem+` | `start` (1) | | `listItem` | | `paragraph block*` | `blockId`, `color` | | `taskList` | block, list | `taskItem+` | | | `taskItem` | | `paragraph block*` | `checked` (false), `blockId`, `color` | | `table` | block | `tableRow+` | `blockId` | | `tableRow` | | `(tableCell \| tableHeader)*` | | | `tableHeader` | | `paragraph+` | `colspan` (1), `rowspan` (1), `colwidth` (null) | | `tableCell` | | `paragraph+` | `colspan` (1), `rowspan` (1), `colwidth` (null) | | `toggle` | block | `toggleSummary block*` | `open` (false), `blockId`, `color` | | `toggleSummary` | | `inline*` | | | `embed` | block | atom | `kind` (null), `ref` (null), `data` (null), `blockId` | | `text` | inline | | | | `pageLink` | inline | atom | `pageId` (null), `label` (null), `heading` (null), `blockRef` (null) | | `tag` | inline | atom | `name` (null) | | `hardBreak` | inline | leaf | | ### 5.2 Marks | Mark | Attributes | Notes | |---|---|---| | `bold`, `italic`, `underline`, `strike` | | | | `code` | | Excludes every other mark. | | `link` | `href` (null), `title` (null) | Not inclusive. `href` must pass `isSafeHref` (http, https, mailto, tel, relative). | | `highlight` | `color` (null = yellow) | `TEXT_COLORS` names. | Block colors (`color` attribute) are `BLOCK_COLORS`: a text color (`red`) or a background (`red-background`). ### 5.3 Embeds, links and tags - **`embed` is the only extension block.** Anything that isn't core text formatting is an embed: - `kind: 'database'`: `ref` = database ID, `data` = `{ viewId }` (an inline database or linked view). - `kind: 'web'`: `ref` = the URL (YouTube, Vimeo, Loom, Figma, CodePen, or a link card). - `kind: 'file'`: `ref` = asset ID (attachments). - `kind: 'plugin:/'`: `data` = the plugin's JSON (`pluginBlockKind(id, type)`). Kinds match `EMBED_KIND_PATTERN`; `data` is JSON of at most 64 KB (`MAX_EMBED_DATA_BYTES`). An unknown kind renders a "This block needs a plugin" placeholder and never crashes. - **`pageLink`** stores the target `pageId`, plus an optional `label` (alias text), `heading` (link to a heading) and `blockRef` (link to a block ID). Renderers show the target's current title (or the label), a broken style for missing or trashed targets, and navigate with `ctx.navigate(pageId, { heading, blockId: blockRef })`. - **`tag`** stores `name` without `#`. Names match `TAG_NAME_PATTERN` (letters, digits, `_`, `-`, `/` for nesting). `tagKey(name)` is the case-insensitive key for search; `tagHierarchy('a/b')` returns `['a', 'a/b']`. ### 5.4 DocJSON utilities `DocJSON` is ProseMirror JSON for this schema (typed node by node: `ParagraphJSON`, `EmbedJSON`, …). | Function | What it does | |---|---| | `readDocJSON(ydoc)` | Reads a page doc's content as normalized DocJSON. | | `writeDocJSON(ydoc, json, { origin? })` | Replaces the content in one transaction (validates first). | | `updateDocJSON(ydoc, update)` | Read, transform, write. | | `createDocFromJSON(json)` | A new Y.Doc holding the content. | | `validateDocJSON(json)` | `{ ok: true, doc, node }` or `{ ok: false, errors }`, against the schema and the attribute rules. | | `normalizeDocJSON(input)` | Repairs anything close to valid (wraps inline content, drops unknown attributes, merges text). Importers and codecs call it last. | | `docJSONToNode`, `docJSONEqual`, `isDocJSON`, `emptyDocJSON`, `isDocEmpty`, `walkDocJSON`, `nodeAtPath` | Helpers. | | `extractPlainText`, `extractTextBlocks`, `extractHeadings` (with `headingSlug`) | Text for search and outlines. | | `extractLinks` | Target page ID, label, heading, block ref and the text of the containing block. | | `extractTags`, `extractTasks`, `extractEmbeds`, `extractImages`, `extractAssetIds` | | | `findTextOccurrences`, `replaceTextWithPageLink` | Unlinked mentions and "Link" conversion. | | `build` (`b.doc`, `b.paragraph`, `b.pageLink`, …), `kitchenSinkDoc`, `FIXTURE_IDS` | Builders and a fixture with every node and mark (in `@tessera/core/testing`). | ```ts const handle = await ctx.loadPageDoc(pageId); try { const doc = readDocJSON(handle.doc); const links = extractLinks(doc); // [{ pageId, label, heading, blockRef, blockText, path, offset }] } finally { handle.release(); } ``` ## 6. Runtime and interfaces Every interface below is defined, documented with TSDoc and an example, and has an in-memory or stub implementation with tests in `packages/core`. ### 6.1 Boot and lifecycle 1. `apps/web/src/main.tsx` creates the device settings store, applies the theme, loads the translations for the chosen language, then imports `features/index.ts`. 2. `createAppRuntime({ features, deviceSettings })` validates the feature list (duplicate IDs are dropped with an error) and resolves the **app** services. 3. The shell lists workspaces (onboarding when there are none) and calls `runtime.openWorkspace(info, bridge)`, which resolves the **storage** services, loads the workspace doc, resolves the **index** services, registers every feature's static contributions and commands, runs each `activate(ctx)` (isolating failures), then emits `workspace.opened`. 4. Closing a session runs cleanups, emits `workspace.closed`, flushes events and doc writes, and disposes services. The runtime debounces `doc.changed` and `database.changed` (750 ms, at most 3 s) and bumps `PageMeta.updatedAt` after local content edits (1.5 s). A feature whose `activate` throws loses its contributions and shows one error toast; the rest of the app keeps working. ### 6.2 `AppContext` What every feature receives (`activate(ctx)`, `useAppContext()`): | Member | Purpose | |---|---| | `workspace` | `WorkspaceApi`: `info`, `doc`, `pages` (reactive `PagesStore`), `getPage`, `createPage`, `renamePage`, `movePage`, `setIcon`, `setCover`, `setFavorite`, `trashPage`, `restorePage`, `deletePagePermanently`, `emptyTrash`, `duplicatePage`, `createDatabase`, `addDatabaseRow`, `addDatabaseRows`. | | `acquirePageDoc(id)`, `acquireDatabaseDoc(id)` | Ref-counted `DocHandle` leases. Always `release()`. | | `loadPageDoc(id)`, `loadDatabaseDoc(id)` | Acquire and wait until loaded. | | `services`, `serviceSources` | The resolved services and which implementation each one is. | | `events`, `commands`, `blocks`, `contributions`, `importers`, `exporters` | Registries (below). | | `settings.device`, `settings.workspace` | Settings stores. | | `currentUser`, `platform` | `{ id, name, color }`; `{ os, isDesktopApp, isTouch, isApple }`. | | `navigate(pageId, { heading?, blockId?, replace? })`, `navigateTo(path)`, `getCurrentPageId()` | Navigation. | | `switchWorkspace(workspaceId)` | Makes the shell open another workspace from the registry (this session closes). With the current workspace's ID it reopens it, so services resolve again (after connecting it to a server). | | `openSidePanel(id)`, `closeSidePanel()` | The right-hand panel. | | `toast(options)`, `confirm(options)` | Notifications (with an undo `action`) and confirmation dialogs. | **`DocHandle`**: `{ docName, kind, id, doc, isLoaded, whenLoaded, error, sync, release() }`. Docs load through the `DocStore`, connect through the `SyncProvider` (`handle.sync.awareness` is always there, also local-only), persist every update, and close 5 s after the last release. Another tab's writes arrive through `DocStore.watch`. Busy docs are compacted on close. ```ts const handle = ctx.acquirePageDoc(pageId); await handle.whenLoaded; const fragment = getPageContent(handle.doc); // the editor binds TipTap Collaboration to this // … handle.release(); ``` In React, use `usePageDoc(pageId)` / `useDatabaseDoc(id)`, which return `{ handle, loaded, error }` and release on unmount. ### 6.3 `FeatureModule` and extension points `apps/web/src/features//index.ts` exports one module built with `defineFeature`. The shell wraps every contributed component in an error boundary (`FeatureBoundary`) labeled with the feature ID. | Field | Rendered where | Props or input | |---|---|---| | `routes` | The main area (sidebar and top bar stay). With `layout: 'bare'` the route fills the window on its own (quick capture, print views). Paths must not collide with `/`, `/p/*`, `/trash`, `/settings/*`, `/dev/*`. | none | | `sidebarSections` | Sidebar: `top` (under "New page") or `bottom` (under the page tree). | none | | `commands` | `ctx.commands`, palette and shortcuts. | `CommandContext` | | `pageBodies` | The page view, by page kind. `page` = editor (Agent 02), `database` = databases (Agent 04). A clean placeholder shows until one is registered. | `PageBodyProps` | | `pageTopSections` | Between the title and the body (row properties). | `PageSectionProps`, optional `when(page, ctx)` | | `pageFooterSections` | After the body (the optional backlinks footer). | `PageSectionProps`, optional `when(page, ctx)` | | `pageHeaderActions` | The top bar while a page is open (presence avatars). | `PageSectionProps` | | `pageSidePanels` | The right panel host (a full-screen sheet at phone width), with toggles in the top bar. Well-known IDs in `PANELS`: `backlinks`, `local-graph`, `history`; plugins use `plugin:/`. | `SidePanelProps` | | `topBarItems` | The top bar, always (sync status). | none | | `blockRenderers` | `ctx.blocks`: components for `embed` kinds and slash-menu items. | `BlockRendererProps` | | `editorExtensions` | Extra TipTap extensions (keymaps, decorations). They must not add nodes or marks. | `create(ctx)` | | `settingsPanels` | Settings, at `/settings/`. | none | | `onboardingActions` | The first-run screen ("Import from Notion", "Open demo workspace"). The shell creates the workspace, opens it, then calls `run(ctx)`. | `run(ctx)` | | `overlays` | Always mounted, for UI that commands open: the command palette, the import and export dialogs. Render nothing until opened (your own store holds the open state) and lazy-load the content. | none | | `workspaceMenuItems` | The sidebar's workspace menu, under "New workspace" (the desktop app's "Open folder…"). | `run(ctx)` | | `docViewers` | Read-only views of a `DocJSON` the way its page shows it (the editor registers one); previews such as version history use the first. Rendered in a `FeatureBoundary` and `Suspense`. | `DocViewerProps`: `{ doc, pageId }` | | `importers`, `exporters` | `ctx.importers`, `ctx.exporters`. | | | `services` | Service resolution ([6.4](#64-services-and-priorities)). | | | `activate(ctx)` | Runs per workspace session after services resolve; may return a cleanup. Register runtime contributions here with `ctx.contributions.register(kind, item, featureId)`. If it throws, everything the feature registered, statically or through `ctx` during `activate` (commands, blocks, contributions, importers, exporters, event handlers), is removed. | `AppContext` | **`PageBodyProps`**: `{ pageId, page, readOnly, target, focusTitle(position?), registerFocusHandler(handler) }`. `readOnly` is true for trashed pages. `target` is `{ heading?, blockId? }` when the navigation asked to scroll somewhere. Title and body hand focus to each other: Enter (or ArrowDown at the end) in the title calls the handler the body registered; the body calls `focusTitle('end')` on ArrowUp at its start. `COMMANDS.focusTitle` focuses the title from anywhere. ```ts // apps/web/src/features/backlinks/index.ts import { defineFeature, PANELS } from '@tessera/core'; import { Link2 } from 'lucide-react'; import { lazy } from 'react'; import { t } from '@tessera/search/i18n'; const BacklinksPanel = lazy(() => import('@tessera/search/backlinks-panel')); export const backlinksFeature = defineFeature({ id: 'backlinks', pageSidePanels: [{ id: PANELS.backlinks, title: t('backlinks'), icon: Link2, component: BacklinksPanel }], }); ``` Keep registration modules thin: static imports of a module put it in the startup bundle. Export heavy components from subpaths of your package (`"exports": { "./backlinks-panel": "./src/backlinks-panel.tsx" }`) and load them with `lazy()` or `import()`. ### 6.4 Services and priorities | Service | Phase | In-memory stub (priority 0) | Real implementations | Owner | |---|---|---|---|---| | `workspaceRegistry` | app | `MemoryWorkspaceRegistry` | IndexedDB (50), Tauri (100) | 03, 07 | | `markdownCodec` | app | `BasicMarkdownCodec` (paragraphs and headings) | remark codec (50) | 08 | | `credentialStore` | app | `MemoryCredentialStore` (browsers sign in with cookies) | IndexedDB (50), OS keychain (100) | 03, 07 | | `fileSaver` | app | `DownloadFileSaver` (a download link, `window.print()`) | native save dialog and webview print (100) | 07, 08 | | `docStore` | storage | `MemoryDocStore` | IndexedDB (50), Tauri SQLite (100) | 03, 07 | | `assetStore` | storage | `MemoryAssetStore` (object URLs) | IndexedDB (50), Tauri files (100) | 03, 07 | | `syncProvider` | storage | `LocalSyncProvider` (local only, awareness without network) | Hocuspocus (50, available only when `workspace.serverUrl` is set) | 03 | | `searchIndex` | index | `NaiveSearchIndex` (substring) | MiniSearch (50) | 05 | | `linkIndex` | index | `NaiveLinkIndex` | Graph link index (50) | 05 | `SERVICE_PRIORITY = { memory: 0, browser: 50, desktop: 100 }`. For each service the runtime tries registrations from the highest priority down (equal priorities keep feature load order), skips those whose `isAvailable` returns false or throws and those whose `create` throws, and falls back to the stub. `ctx.serviceSources` shows the winner. Phases decide what `create` receives: `app` gets `{ platform, settings }`; `storage` adds `{ workspace, app, currentUser, events }` (`app` holds the workspace registry, the codec, the credential store and the file saver); `index` adds `{ storage, workspaceDoc, pages, loadPageDoc, loadDatabaseDoc }`. The stubs of the markdown codec and the indexes load on demand, so they never weigh on the startup bundle. ```ts services: [ defineService({ provides: 'docStore', id: 'indexeddb', priority: SERVICE_PRIORITY.browser, isAvailable: () => typeof indexedDB !== 'undefined', create: async ({ workspace }) => (await import('@tessera/sync/stores')).IndexedDbDocStore.open(workspace.id), }), ], ``` **`DocStore`**: `load(docName) → Uint8Array | null` (the merged state), `storeUpdate(docName, update)` (durable before resolving), `compact(docName)`, `delete(docName)`, `list(prefix?)`, optional `watch(docName, onUpdate)` for other tabs' writes, `flush()`, `dispose()`. `watch` may report an update before it is durable (the IndexedDB store announces writes before their commit, which can take seconds on a busy disk); the store then makes sure it ends up stored even if its tab closes first, and the runtime never stores watched updates itself. ```ts const update = Y.encodeStateAsUpdate(doc); await docStore.storeUpdate('page:abc', update); const state = await docStore.load('page:abc'); // Y.applyUpdate(fresh, state!) ``` **`AssetStore`**: `put(blob, { name?, mimeType? }) → { assetId, url }`, `get(id) → Blob | null`, `getUrl(id)` (valid for the session), `delete(id)`, optional `retainUrl(id) → { url, release() }` (object URLs are revoked once nobody holds them), `getInfo(id)`, `list()`, `dispose()`. Asset IDs match `ASSET_ID_PATTERN`. Images reference `assetId`, never a URL, so they work offline and across devices. ```ts const { assetId } = await ctx.services.assetStore.put(file, { name: file.name }); editor.commands.insertContent({ type: 'image', attrs: { assetId, alt: '' } }); ``` **`SyncProvider`**: `connect(docName, ydoc) → SyncHandle`, aggregate `getStatus()` and `onStatus(listener)`. A `SyncHandle` has `getStatus()`, `onStatus()`, `awareness`, `whenSynced()` and `destroy()`. Statuses: `local`, `offline`, `connecting`, `syncing`, `synced`, `error` (`SyncStatusInfo` adds `error`, `lastSyncedAt`, `pendingUpdates` and `readOnly`: the server gave this device a viewer's read-only connection, so the shell makes pages and the page tree read-only). The `DocManager` calls `connect` for every loaded doc; nothing else does. Never persist awareness. ```ts const status = useSyncStatus(handle?.sync); // { status: 'synced', lastSyncedAt: … } ``` **`CredentialStore`**: `get(server)`, `set(server, token)`, `delete(server)`, `list() → { server, savedAt }[]`, keyed by server origin. Only the desktop app needs tokens (browsers use httpOnly cookies); it keeps them in the OS keychain. **`WorkspaceRegistry`**: `list()` (most recent first), `get(id)`, `create({ name, icon?, serverUrl?, path?, id? })`, `open(id)` (marks it opened), `rename`, `update(id, patch)`, `remove(id)`, `subscribe(listener)`. `WorkspaceInfo` = `{ id, name, icon?, serverUrl?, path?, createdAt, lastOpenedAt? }`. **`SearchIndex`**: `upsert(pageId)`, `remove(pageId)`, `query(q, options) → { hits, total }`, optional `rebuild()`. Options: `limit`, `offset`, `kinds`, `withinPageId` (`in:`), `tags` (`tag:`), `hasTasks` (`is:task`), `includeRows`, `signal`. A hit has `pageId`, `title`, `kind`, `score`, `matchedIn`, `titleHighlights`, an optional `snippet { text, highlights }` (ranges into the text) and optional `heading` and `blockId` for `ctx.navigate` to scroll to the match. The index keeps itself current from `EventBus` events; trashed pages disappear immediately. ```ts const { hits } = await ctx.services.searchIndex.query('apollo tag:space', { limit: 10 }); ``` **`LinkIndex`**: `backlinks(pageId)` (with the containing block's text and `blockId`), `outgoing(pageId)`, `unlinkedMentions(pageId)` (title and aliases as plain text, word boundaries, case-insensitive), `edges()` for the graph, `subscribe(listener)`. ```ts const mentions = await ctx.services.linkIndex.unlinkedMentions(pageId); // Convert one (in the source page's doc): updateDocJSON(sourceHandle.doc, (doc) => replaceTextWithPageLink(doc, mentions[0], { pageId })); ``` **`MarkdownCodec`**: `parse(markdown, { resolvePageLink?, resolveAsset? }) → { doc, frontmatter, warnings }`, `serialize(doc, { linkStyle?, resolvePage?, resolveAssetPath?, frontmatter?, keepBlockId? }) → string` (`keepBlockId` picks the block IDs written as Obsidian ` ^id`: the markdown export keeps the ones links point at, the editor's copy none, since the editor gives most blocks an ID), `parseHTML(html) → DocJSON` (sanitized). Default link style is `[[wikilinks]]`. Synchronous, so it can run on paste. ```ts const { doc } = ctx.services.markdownCodec.parse(clipboardText); const md = ctx.services.markdownCodec.serialize(readDocJSON(handle.doc), { linkStyle: 'wikilink' }); ``` **`Importer`**: `{ id, label, description?, accept?, acceptsDirectories?, detect(files) → 0..1, run(files, context, onProgress, signal) → ImportReport }`. Registering an importer or exporter under an existing ID replaces it; core registers its stubs as `replaceable`, so replacing them logs nothing, and removing the replacement brings the stub back. Files are `ImportFile`s with normalized paths (`normalizeImportPath` rejects `..` and absolute paths). `ImportContext` gives `workspace`, `loadPageDoc`, `loadDatabaseDoc`, `assets`, `codec`, `rootTitle` and `parentId`: imports land under a new top-level page. The report has counts, issues (warnings and errors with page links) and timing. **`Exporter`**: `{ id, label, scopes, fileExtension?, run(scope, context, sink, onProgress, signal) → ExportResult, session?(scope, context) → ExportSession }` writing to an `ExportSink` (a zip, a folder for the desktop mirror, or `MemoryExportSink` in tests). An `ExportSession` keeps an export up to date in place: `run(changed, sink, onProgress, signal)` writes only the files that changed since its previous run (`changed` names the pages and databases whose docs changed; renames, moves, deletions and what links show of other pages it works out itself) and returns `paths`, every file the export has now, so a caller can delete the rest. The desktop mirror uses it when the exporter has one. Core ships a basic markdown importer and exporter (without sessions); Agent 08 replaces them. ```ts const [best] = await ctx.importers.detect(files); const report = await best.importer.run(files, { ...importContext, rootTitle: 'Notion import' }, setProgress, controller.signal); ``` ### 6.5 Registries **`CommandRegistry`** (`ctx.commands`): `register`, `registerMany`, `get`, `has`, `list`, `available`, `execute(id, { args?, source? }) → boolean`, `findForEvent(keyboardEvent)`, `subscribe`. A command is `{ id: '.', title, keywords?, shortcut?, group?, icon?, hidden?, allowInEditable?, when?, run }`. Shortcuts use `Mod` for ⌘ on Apple platforms and Ctrl elsewhere (`formatShortcut` renders them). Single-key shortcuts don't fire in text fields unless `allowInEditable`. ```ts ctx.commands.register({ id: 'editor.wordCount', title: t('editor:wordCount'), group: 'editor', run: ({ pageId }) => showWordCount(pageId) }); await ctx.commands.execute(COMMANDS.search, { args: { query: '#space' } }); ``` Well-known command IDs (`COMMANDS`): | ID | Owner | Shortcut | |---|---|---| | `shell.newPage` (args `{ parentId? }`) | shell | Mod+N, Mod+Alt+N | | `shell.toggleSidebar` | shell | Mod+\ | | `shell.toggleTheme` | shell | Mod+Shift+L | | `shell.showShortcuts` | shell | `?`, Mod+/ | | `shell.openSettings` | shell | Mod+, | | `shell.openTrash`, `shell.focusTitle` | shell | | | `search.openPalette` | search | Mod+K (reserved in `RESERVED_SHORTCUTS`) | | `search.open` (args `{ query }`) | search | tag clicks run it with `#tag` | | `graph.open` | graph | | | `importExport.openImport` (args `{ importerId? }`), `importExport.openExport` (args `{ pageId? }`) | import-export | | Chromium reserves Mod+N for a new window, so Mod+Alt+N is the one that always works in the browser. Command groups (`COMMAND_GROUPS`): navigation, page, editor, view, workspace, help. **`BlockRendererRegistry`** (`ctx.blocks`): `register({ kind, component, label?, slashMenu? })`, `registerSlashMenuItems(items)`, `resolve(kind)`, `list()`, `slashMenuItems()`, `subscribe`. A `kind` ending in `:` or `/` is a prefix (`plugin:`). Exact kinds beat prefixes; longer prefixes beat shorter ones. Renderers get `BlockRendererProps`: `{ kind, ref, data, blockId, pageId, selected, readOnly, updateData, updateAttrs, deleteBlock }`. Slash-menu items return the embed to insert (`{ kind, ref?, data? }`), possibly after async work. ```ts ctx.blocks.register({ kind: 'database', component: InlineDatabase, slashMenu: [{ id: 'database-inline', title: t('db-views:inlineDatabase'), group: 'database', create: async ({ app }) => { const { page, viewId } = await app.workspace.createDatabase({ title: '', titlePropertyName: t('db-views:name'), viewName: t('db-views:table') }); return { kind: 'database', ref: page.id, data: { viewId } }; } }], }); ``` **`EventBus`** (`ctx.events`): `on`, `once`, `emit`, `listenerCount`, `clear`. Synchronous; a throwing handler is logged and never stops the others. Page events are derived from the workspace doc, so they fire for local and remote changes alike (`local` tells them apart). | Event | Payload | |---|---| | `workspace.opened` / `workspace.closed` | `{ workspace }` / `{ workspaceId }` | | `page.created` | `{ page, local }` | | `page.renamed` | `{ pageId, title, previousTitle, local }` | | `page.moved` | `{ pageId, parentId, previousParentId, local }` | | `page.updated` | `{ page, previous, fields, local }` (any metadata change) | | `page.trashed` / `page.restored` | `{ pageId, affectedPageIds, local }` | | `page.deleted` | `{ pageId, page, local }` (once per removed page) | | `doc.changed` | `{ pageId, docName, local }` (debounced per page) | | `database.changed` | `{ databaseId, docName, local }` (debounced; `observeDatabase` has details) | | `settings.changed` | `{ scope: 'device' \| 'workspace', key }` | | `user.changed` | `{ user }` | | `navigation.changed` | `{ pageId, path }` | ```ts const off = ctx.events.on('page.renamed', ({ pageId, title }) => index.updateTitle(pageId, title)); ``` **`ContributionRegistry`** (`ctx.contributions`): `register(kind, item, featureId)`, `list(kind)` (sorted by `order`), `subscribe`, `getVersion`. The plugins feature registers one panel per plugin here at runtime; `useContributions(kind)` reads it. **`SettingsStore`**: `get(key)`, `set(key, value | undefined)`, `keys(prefix?)`, `subscribe(listener)`. Implementations: `MemorySettingsStore`, `LocalStorageSettingsStore` (cross-tab), `WorkspaceSettingsStore`. ### 6.6 React bindings (`@tessera/core/react`) `AppContextProvider`, `useAppContext`, `useOptionalAppContext`, `usePages` (the reactive page index), `usePagesSelector(select, isEqual?)`, `sameItems`, `usePage(id)`, `usePageTree`, `useAncestors(id)`, `usePageDoc(id)`, `useDatabaseDoc(id)`, `useSyncStatus(syncHandle)`, `useEvent(type, handler)`, `useCommands`, `useContributions(kind)`, `useSetting(store, key, fallback)`, `useCurrentUser`. Never mirror document content into React state: read it from Yjs in effects or through TipTap. `usePages` and `usePageTree` re-render on every page change, and an import makes thousands. A component that shows a few pages selects them: `usePagesSelector` re-renders only when its selection changes (`Object.is`, or `isEqual`, such as `sameItems` for lists). Page objects keep their identity while unchanged, so `usePage(id)` and `useAncestors(id)` re-render only when their pages change. Handlers read `ctx.workspace.pages.getSnapshot()` when they run. ### 6.7 Testing helpers (`@tessera/core/testing`) - `createTestAppContext({ features?, workspaceName?, runtime? })` returns `{ ctx, session, runtime, shell, workspace, flush, dispose }`: an in-memory session with every stub, events delivered immediately and docs closed on release. - `createRecordingShell()`: a `ShellBridge` that records navigations, toasts, confirms and panels. - `kitchenSinkDoc`, `FIXTURE_IDS`: a document with every node and mark. - `@tessera/core/testing/setup-dom`: the Vitest setup for jsdom packages (jest-dom matchers, cleanup, `ResizeObserver`, `matchMedia`, pointer capture and range polyfills). - **Diagnostics in the running app.** While a workspace is open, `window.__tessera.diagnostics()` returns the loaded features, failed features, the implementation behind each service, the contributions per kind (route paths, page-body kinds, IDs), command IDs and block kinds. Names only, no data. End-to-end journeys use it to skip cleanly when a feature isn't there yet (`e2e/architect/helpers.ts` has a typed `readDiagnostics(page)`). ```ts const { ctx, flush, dispose } = await createTestAppContext({ features: [backlinksFeature] }); const page = ctx.workspace.createPage({ title: 'Apollo' }); await flush(); await dispose(); ``` ## 7. App shell `apps/web/src/app` (owned by the Architect). - **Layout.** A resizable, collapsible sidebar (workspace switcher, search when the palette exists, "New page", favorites, contributed sections, the page tree, Trash and Settings), a top bar (breadcrumbs, `topBarItems`, `pageHeaderActions`, favorite, side-panel toggles and the page menu), the main area, and the right side-panel host. Below 768 px the sidebar is a modal drawer and side panels are full-screen sheets. - **Routes.** `/` reopens the last page (or the first, or shows the empty state), `/p/:pageId`, `/trash`, `/settings` and `/settings/`, `/dev/ui` (the component gallery), and feature routes. Unknown paths show a not-found view. Trash and Settings load on demand and are preloaded when the app is idle. - **Page view.** A trash banner (restore or delete forever) when the page is in the trash, the cover, the icon (emoji picker), "Add icon" and "Add cover" on hover, the title (a textarea bound to `PageMeta.title`), `pageTopSections`, then the body from `pageBodies[kind]`, or a placeholder. `fullWidth` and `smallText` page props set `data-full-width` and `data-small-text` on the article. - **Page tree.** An ARIA tree with roving focus: arrows move and expand, Enter opens, Alt+Shift+↑/↓ reorder, Alt+Shift+→ nests under the previous sibling, Alt+Shift+← un-nests. Drag a row onto the top or bottom edge of another to place it before or after, or onto its middle to nest it; drop below the last row to move it to the end of the top level. Row menus and context menus offer the same moves, plus "Add a page inside", favorite, duplicate, copy link and trash. - **Trash.** Trashing shows a toast with Undo. The Trash view filters, restores and deletes forever (confirmed). - **Onboarding.** "Create an empty workspace" plus every registered `onboardingActions` button. - **Settings.** Built-in General (theme, language, display name, cursor color, workspace name, delete workspace) and Keyboard shortcuts sections, then every `settingsPanels` entry. - **Keyboard.** A global shortcut handler runs `ctx.commands.findForEvent`; `?` or Mod+/ opens the shortcuts overlay, which lists every command with a shortcut by group. - **Feature overlays and bare routes.** `overlays` render after the layout, always mounted. A `layout: 'bare'` route replaces the whole layout while it is open; overlays and shortcuts keep working. - **Switching workspaces.** The workspace switcher and `ctx.switchWorkspace(id)` close the current session and open the other one (the same ID reopens it). Unknown IDs show an error toast. - **Theme.** Light, dark or system (`shell.theme`). `apps/web/public/theme-init.js` sets `html[data-theme]` before the first paint; `followTheme` keeps it in sync with the setting and the OS. - **Errors.** Each contribution renders inside a `FeatureBoundary`; a crash shows "Something went wrong here" with Try again, and the rest of the app keeps working. A fatal boot error shows a full screen with the details. - **Viewers.** When the sync provider reports `readOnly` (the viewer role), pages, the title, the page menu, favorites, the page tree and "New page" are read-only. - **Links to places.** `/p/#block-` and `/p/#` (what `ctx.navigate` writes and "Copy link" copies) open the page scrolled to that block or heading. - **Offline.** Production builds register a service worker (`apps/web/service-worker.js`, built to `/sw.js` with the build's file list): it caches the shell and every built file at install, answers page loads network-first (the cached shell when offline) and built files cache-first, and never touches other origins, `/api/` or `/sync`. A new version waits until the old one's tabs close. Not in dev or in the desktop app (its files are local). - **Validation.** zod runs `jitless` (`apps/web/src/zod-config.ts`, imported first): the app's CSP forbids `eval`, so its JIT probe only caused a CSP violation. ## 8. Design system (`packages/ui`) - **Tokens.** CSS variables in `packages/ui/src/styles/tokens.css` (`--tess-*`) with light values on `:root` and dark values on `:root[data-theme='dark']`: surfaces, text, borders, one indigo accent, danger, success, warning, info, ten tag colors, radii, shadows, type scale, motion durations and easing, layout sizes (`--tess-sidebar-width`, `--tess-topbar-height`, `--tess-panel-width`, `--tess-page-width`, `--tess-page-padding`) and z-indexes. Motion uses ease-out at 150, 180 or 200 ms (`duration-fast`, `-normal`, `-slow`); a bare `transition-*` utility defaults to 150 ms ease-out, and all of it turns off under `prefers-reduced-motion`. Text meets WCAG AA (4.5:1) on every surface in both themes, including selected and highlighted rows and inline code (`--tess-code-fg`); `e2e/polish/design.spec.ts` checks every screen with axe. - **Tailwind v4.** `@tessera/ui/styles.css` maps tokens to utilities: `bg-bg`, `bg-bg-subtle`, `bg-surface`, `bg-surface-raised`, `bg-hover`, `bg-active`, `text-fg`, `text-fg-muted`, `text-fg-subtle`, `border-border`, `bg-accent`, `text-accent-text`, `bg-danger`, `bg-tag-blue-bg`, `text-tag-blue-fg`, `shadow-popover`, `animate-pop-in`, `duration-fast`, `text-ui` (13 px), `text-2xs`, and so on. The `dark:` variant follows `data-theme`. Classes used anywhere in `packages/*/src` and `apps/desktop/src` are generated (`@source` globs in `apps/web/src/styles.css`). Never hard-code colors. - **Components** (Radix-based, keyboard accessible, both themes): `Button`, `IconButton` (tooltip and shortcut), `Input`, `Textarea`, `Label`, `Field`, `Select`, `Checkbox`, `Switch`, `RadioGroup`, `RadioCard`, `Tabs`, `ScrollArea`, `Dialog`, `AlertDialog`, `Sheet`, `DropdownMenu`, `ContextMenu`, `Popover`, `HoverCard`, `Tooltip`, `Toaster` with `toast()`, `ConfirmHost` with `confirm()`, `Callout`, `Kbd`, `KeyCombo`, `Spinner`, `Skeleton`, `EmptyState`, `Badge`, `Avatar`, `AvatarStack`, `Separator`, `VisuallyHidden`, `FeatureBoundary`, sidebar primitives (`SidebarRoot`, `SidebarHeader`, `SidebarContent`, `SidebarFooter`, `SidebarSection`, `SidebarItem`), panel primitives (`Panel`, `PanelHeader`, `PanelBody`), `ColorSwatches`, `EmojiPicker`, `COVER_PRESETS`, `cn()`, `useIsCompact()`. See them all at `/dev/ui`. - **i18n.** Every user-facing string goes through `t()`. Each package keeps English strings in `src/i18n/en.ts` and creates a typed translator for its namespace (the package folder name): ```ts // packages/search/src/i18n/index.ts import { createTranslator } from '@tessera/ui'; import { en } from './en'; export const t = createTranslator('search', en); // t('palettePlaceholder'), or t('search:…') anywhere ``` Placeholders are `{name}`; plurals use `_one`/`_other` suffixes with a `count` value. Translations are `src/i18n/.json` files with the same keys; the shell loads them at boot, and changing the language reloads the app. ## 9. Ownership and conventions ### 9.1 Ownership | # | Agent | Branch | Owns (may create and edit) | |---|---|---|---| | 01 | Architect | `main` | root config files, `.claude/`, `packages/core`, `packages/ui`, `apps/web` except the contents of `src/features/*` (the Architect creates those as stubs, then each belongs to its agent), `SPEC.md`, `CLAUDE.md`, `HANDOFF/README.md` | | 02 | Editor | `feat/editor` | `packages/editor`, `apps/web/src/features/editor` | | 03 | Storage & sync | `feat/sync` | `packages/sync`, `apps/server`, `apps/web/src/features/sync` | | 04 | Databases | `feat/databases` | `packages/db-views`, `apps/web/src/features/databases` | | 05 | Search & graph | `feat/search` | `packages/search`, `apps/web/src/features/search`, `apps/web/src/features/graph`, `apps/web/src/features/backlinks` | | 06 | Plugins | `feat/plugins` | `packages/plugins`, `packages/plugin-api`, `packages/create-tessera-plugin`, `apps/web/src/features/plugins`, `examples/plugins`, `examples/plugin-template`, `docs/plugins` | | 07 | Desktop & self-host | `feat/desktop` | `apps/desktop`, `apps/web/src/features/desktop`, `Dockerfile`, `docker-compose.yml`, `.dockerignore`, `deploy/` | | 08 | Markdown, import & export | `feat/importers` | `packages/markdown`, `packages/importers`, `apps/web/src/features/import-export` | | 09 | CI & quality | `feat/ci` | `.github/workflows`, `.github/dependabot.yml`, `.github/CODEOWNERS`, `.github/labeler.yml`, `packages/testkit`, `scripts/`, `e2e/journeys`, `e2e/support`, `SECURITY.md` | | 10 | Docs & launch | `feat/docs` | `README.md`, `docs/` except `docs/plugins`, `assets/` except `assets/screenshots`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `LICENSE`, `.github/ISSUE_TEMPLATE`, `.github/PULL_REQUEST_TEMPLATE.md`, `examples/demo-workspace`, `LAUNCH.md`, `BUILT_WITH_AGENTS.md` | Every agent also owns `HANDOFF/.md`, `assets/screenshots//` and `e2e//`. ### 9.2 Conventions - **Code.** TypeScript strict, ESM, no `any` (use `unknown` and narrow), no `@ts-ignore`, no unexplained `eslint-disable`. Prettier (100 columns, single quotes, trailing commas) formats everything; `pnpm lint` checks ESLint and Prettier. - **Names.** Feature IDs: `editor`, `sync`, `databases`, `search`, `graph`, `backlinks`, `plugins`, `import-export`, `desktop`. Commands `.`, settings `.`, i18n namespaces = package folder names, events `noun.verb`. - **Feature folders stay thin.** `apps/web/src/features//index.ts` only registers. Components and logic live in your package, exported through subpaths for lazy loading. - **Errors.** Throw `TesseraError` subclasses with a stable `code` (`not_found`, `invalid`, `invalid_operation`, `conflict`, `permission_denied`, `unavailable`, `aborted`, `internal`). In UI, turn failures into toasts or inline errors; never let them escape into React. - **Async UI.** Every async view has loading, empty and error states. Destructive actions are undoable (toast with Undo) or confirmed (`ctx.confirm`). - **Accessibility.** Radix primitives or correct ARIA, visible focus (`focus-visible:ring-focus`), every action reachable by keyboard, labels on icon buttons, works at 390 px wide. - **Tests.** Vitest next to the code (`*.test.ts(x)`), Playwright in `e2e//*.spec.ts`. The root configs discover both, so new packages and e2e folders need no root edit. Never skip, weaken or delete a test to make it pass. - **Screenshots.** Write `e2e//*.screenshots.ts` and run `pnpm screenshots e2e/` (Chromium, 1440×900). Save `assets/screenshots//-light.png` and `-dark.png` with realistic content (switch themes with `page.emulateMedia({ colorScheme })`; the app follows the system theme by default). `e2e/architect/shell.screenshots.ts` is a working example. - **e2e ports.** Each worktree gets its own port (`playwright.config.ts` maps `-` folders to 4210–4290), so parallel agents never share a server. `E2E_DEV=1` uses the Vite dev server instead of a production build; `E2E_PORT` overrides the port. - **e2e environment.** Service workers are blocked (requests they answer bypass `page.route`); `e2e/architect/offline.spec.ts` turns them on. Firefox gets a 90 s test budget (the same steps run two to three times slower there). Specs assert the features they need with `app.expectFeatures(…)`, which fails, never skips. `TESSERA_E2E_SERVER_URL` (with `TESSERA_E2E_SETUP_CODE`) points the `syncServer` and `collaborators` fixtures at a running server, such as `docker compose up`. - **Dependencies.** Prefer what's installed (section 13). Add new ones only to a `package.json` you own, pinned exactly, MIT/Apache-2.0/BSD/ISC, justified in HANDOFF. - **Commits.** Conventional Commits (`feat(editor): add slash menu`), small and often, on your branch. ## 10. Performance budgets | Budget | Target | How it's measured | |---|---|---| | Startup JS (the shell, before the editor chunk) | ≤ 250 KB gzip | `vite build` output: the entry chunk plus its static imports. Baseline with every feature stubbed: 215 KB. | | Cold start with 5,000 pages | < 2 s to an interactive sidebar | `scripts/bench` (Agent 09) | | Typing latency on a 2,000-block page | < 16 ms p95 in Chromium (also the Windows desktop engine) and Firefox | `e2e/editor/performance.spec.ts` (the editor's processing per keystroke) | | Search | < 50 ms p95 per query on 5,000 pages; palette < 50 ms p95 per keystroke | Agent 05's benchmark | | Database filtering | < 50 ms for 10,000 rows; table scrolls 10,000 rows at 60 fps | Agent 04's performance tests | | Graph | fluid with 10,000 nodes; layout off the main thread | Agent 05 | | Import | 2,000 files without freezing the UI (worker) | Agent 08 | | Memory | no growth over 10 minutes of editing | Agent 12 | Staying in budget: - The startup bundle is everything the entry chunk imports statically. Registration modules import only light things; components, workers and heavy libraries (TipTap, sigma, MiniSearch, remark, zod schemas) load through `lazy()`/`import()`. - Chunks split per module, not per function. A module the shell needs at startup pulls every function in it (and its imports) into the entry chunk. Keep heavy helpers in their own modules. This is why `headingSlug`, the zod schemas and the stub services live in separate files in core. - `vite build` warns above 750 KB minified per chunk, roughly the budget at a 3.2:1 gzip ratio. Agent 09's bundle report enforces the real number in CI. ## 11. Security - **Untrusted input is validated at every trust boundary with zod**: server configuration and HTTP input, plugin manifests and every plugin RPC message (on the host side), imported files, pasted HTML, network responses, and data read from other peers (core helpers read Yjs data defensively and never trust its shape). - **HTML** from anywhere (paste, imports, embeds, plugins) is sanitized with DOMPurify before it is rendered or converted. Nothing renders unsanitized HTML. No `eval`, no `new Function`, no `dangerouslySetInnerHTML` with unsanitized input. - **URLs.** Links render only when `isSafeHref` accepts them; images use asset IDs or `isSafeImageSrc` sources; covers from URLs must be `https:`. Web embeds render in sandboxed iframes from an allowlist of providers. - **Plugins** run in sandboxed iframes (`sandbox="allow-scripts"` without `allow-same-origin`) with a strict CSP, talk only through a validated postMessage RPC, and get capability-scoped APIs checked against their granted permissions on every call. Crashes and timeouts are contained. Panel, block and renderer frames may share the app's thread, so their code is instrumented before it runs and stopped after 2 s without a break, and no other script can load in them. A plugin's heavy drawing code goes in its renderer (manifest `renderer`, plugin API 2): one hidden frame per plugin, without API access, that its panels and blocks call with `api.ui.render`, so a library like Mermaid loads once rather than in every block. The frames are `srcdoc` documents, so the app's own policy applies to them too: the server sends `script-src 'self' 'nonce-…' blob:` with a fresh nonce per `index.html` response and writes it into `` (the desktop app fills the same token with Tauri's nonce), and the frames' bootstraps carry that nonce. - **Server.** Authorization is always enforced on the server (viewers get read-only connections, even for hand-crafted messages). argon2id password hashes, httpOnly SameSite session cookies, bearer tokens for the desktop app, rate-limited auth endpoints, security headers, upload size limits, content-type allowlists and no path traversal. - **Files.** Archive and folder paths are normalized; `..`, absolute paths and drive letters are rejected (`normalizeImportPath`, the manifest's bundle paths). - **Secrets** never enter the repo (signing keys, tokens). Desktop tokens go to the OS keychain. - **Dependencies** are pinned; Agent 09 runs CodeQL, Dependabot and `pnpm audit`. ## 12. Merge plan 1. The Architect reads every `HANDOFF/*.md` and writes `HANDOFF/integration.md`: merge order, every contract change request (approved or rejected, with reasons) and every follow-up. 2. Approved contract changes land in `packages/core` first, in one commit, with this document updated. 3. Branches merge one at a time with `git merge --no-ff`, in this order unless the HANDOFF files suggest better: `feat/ci`, `feat/sync`, `feat/editor`, `feat/importers`, `feat/search`, `feat/databases`, `feat/plugins`, `feat/desktop`, `feat/docs`. Lockfile conflicts: take `main`'s version and run `pnpm install`. 4. After each merge: `pnpm install && pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e`, run the app, look at the feature and its screenshots, and fix root causes before the next merge. 5. Then the cross-feature wiring no single agent could finish: collaboration cursors in the editor, copy and paste through the real codec, search indexing of database rows, inline databases in pages, plugin blocks in the slash menu, importers writing to the asset store, the desktop markdown mirror through the exporter, "Open demo workspace", history previews with the editor, and the plugin docs in the docs sidebar. **Contract change requests.** Never edit `packages/core` or another agent's files. Write the request in your HANDOFF under *Contract change requests* with the exact diff (file, before, after), why you need it, and the local workaround you used meanwhile. Code against the current contract so your branch builds on its own. ## 13. Stack and decisions ### 13.1 Versions (all pinned exactly) | Area | Choice | |---|---| | Tooling | Node 24 LTS (`.nvmrc`), pnpm 11.9.0 workspaces, TypeScript 6.0.3 (strict, ESM, `moduleResolution: bundler`), ESLint 9.39.5 flat config with typescript-eslint 8.70.1, jsx-a11y and react-hooks, Prettier 3.9.8 with the Tailwind plugin, tsdown 0.23 for library builds, tsx | | App | Vite 8.3 (Rolldown), React 19.3, React Router 8.4 (declarative mode), Zustand 5.0.15 for UI state | | UI | Tailwind CSS 4.3 with CSS-variable tokens, Radix UI 1.6.7 (the unified `radix-ui` package), lucide-react 1.47, Inter Variable, emojibase-data 17 (lazy) | | Documents | Yjs 13.6.32, y-prosemirror 1.3.7, prosemirror-model 1.25.12, TipTap 3.31.3 (with `@tiptap/y-tiptap`), lowlight 3.3 | | Data | zod 4.6.5, nanoid 6.0.1, fractional-indexing 4.0.0 | | Sync and server | Hocuspocus 4.7 (server and provider), y-indexeddb 9.0.12, better-sqlite3 13.0.3, Hono 4.13.8 with @hono/node-server, argon2 0.45.1, pino 10.3.1 | | Databases | @tanstack/react-table 9.2.4, @tanstack/react-virtual 3.14.13, @dnd-kit 6.3.1, papaparse 5.7.0 | | Search and graph | MiniSearch 7.2.0, graphology 0.26.0, sigma 3.0.3, graphology-layout-forceatlas2 0.10.1 | | Markdown and import | unified 11, remark-parse 11, remark-gfm 4.0.1, remark-frontmatter 5, remark-stringify 11, DOMPurify 3.4.15, yaml 2.9.1, fflate 0.8.3, fast-check 4.10.2 (tests) | | Desktop | Tauri 2.11 (`@tauri-apps/api`, `@tauri-apps/cli`) | | Tests | Vitest 5 (projects discovered from `packages/*` and `apps/*`), Testing Library, jsdom 30, Playwright 1.63 (Chromium and Firefox) | ### 13.2 Deviations from the defaults, and why - **TypeScript 6.0.3, not 7.** typescript-eslint 8.70 supports TypeScript below 6.1 only. - **ESLint 9, not 10.** `eslint-plugin-jsx-a11y` 6.10 declares support up to ESLint 9. - **Hono** (not Fastify) for the server's HTTP API: small, fast, standard `Request`/`Response`, and it shares the Node server with Hocuspocus easily. - **React Router in declarative mode** (`BrowserRouter`) with `useTransitions={false}`: a navigation renders in the same pass as the document change that caused it (creating a page from `/` must not let the home view redirect first). ### 13.3 Contract refinements (beyond the original brief) - Page metadata lives in **nested `Y.Map`s per page**, not plain objects, so concurrent edits to different fields merge. - **Rows are pages** (`rowId === pageId`); row titles are `PageMeta.title`; rows hold `{ order, values, valuesUpdatedAt, valuesUpdatedBy }`. - **Implicit trash** for descendants, **orphans at the top level**, **deterministic cycle breaking**. - Schema additions: a `blockId` attribute on linkable blocks, a `color` attribute on text blocks, table cells hold `paragraph+`, `pageLink` gained `blockRef`, `link` gained `title`, embeds gained `file`. `SyncStatus` gained `syncing`. - **Service phases** (`app`, `storage`, `index`) so each service gets exactly the context it needs. - `PageBodyProps` gained `target`, `focusTitle` and `registerFocusHandler`; features can register contributions at runtime (`ctx.contributions`); `editorExtensions` exist so other features can extend the editor without adding nodes. - Extension points added for needs in the agent files: `overlays` (palette, dialogs), `pageFooterSections` (backlinks footer), `layout: 'bare'` routes (quick capture) and `ctx.switchWorkspace` (workspace folders, connecting to a server). - The current user's ID is a live device setting, so signing in can switch it to the account ID. - Added at merge time from the agents' contract change requests (`HANDOFF/integration.md`): `createPages`, `addRows`, `checkRowValues` and `ctx.workspace.addDatabaseRows` (bulk creation), `MarkdownSerializeOptions.keepBlockId` (clean markdown exports and copies), the `docViewers` contribution (history previews with the editor), the `credentialStore` service, `AssetStore.retainUrl`, `SyncStatusInfo.readOnly` (read-only viewers), `SearchHit.heading`/`blockId`, `Backlink.blockId`, the `workspaceMenuItems` contribution, and replaceable registrations for core's stub importer and exporter. - Plugin API 2: `pluginManifestSchema` gained `renderer` (a second module, loaded once per plugin in a hidden frame), and `PLUGIN_API_VERSION` is 2, so Tessera 0.1 refuses plugins that need a renderer instead of running them without one. - The `fileSaver` service (0.1.3): exports save and print through it, because the desktop webviews ignore `` and WKWebView ignores `window.print()` (#30). `save` resolves to null when the user cancels the save dialog.