Files
crumbs-original/docs/ARCHITECTURE.md
T
Claude 58e5b5dd66 feat(editor): make code snippets readable and syntax highlighted
Closes #90.

Code blocks and inline code inherited the typography plugin's defaults,
which shrank them to ~86% of an already-monospace body and painted them
--bg-surface — the same colour as the default note card — so a snippet
was only recognisable by being smaller. Long lines also grew a horizontal
scrollbar inside card previews.

- New --code-bg surface (one step below the page in light mode, page-level
  ink in dark) with a subtle border, and code keeps the surrounding text
  size. Decorative backtick pseudo-elements dropped now that the chip
  carries the signal.
- Card previews clip wide code instead of scrolling; editor and share page
  keep horizontal scroll.
- Syntax highlighting via one shared lowlight instance feeding both the
  TipTap editor (extension-code-block-lowlight) and the markdown-it
  renderer, so a fence looks identical in the editor, on cards and on the
  share page. Only highlight.js "common" grammars are bundled.
- No auto-detection: highlight.js misreads note-sized snippets too often
  (JavaScript as CSS, SQL as VB.NET). Unlabelled fences stay plain; the
  language comes from the fence info string or a toolbar picker shown
  while the cursor is in a code block.
- Six --hl-* token colours per theme; a unit test parses app.css and
  asserts AA contrast against --code-bg and mutual distinguishability.
- Sanitizer now allows only language-* / hljs-* classes on code and span.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-17 21:42:35 +02:00

8.8 KiB

Architecture

Overview

Crumbs follows a local-first architecture where the client (browser) is the primary data store and the server acts as a sync target and backup.

┌─────────────────────────────┐
│         Browser (PWA)        │
│  ┌───────────┐ ┌──────────┐ │
│  │  Svelte   │ │ IndexedDB│ │
│  │  Stores   │◄│  (idb)   │ │
│  └─────┬─────┘ └────┬─────┘ │
│        │             │       │
│  ┌─────▼─────────────▼─────┐ │
│  │      Sync Engine        │ │
│  │  (LWW CRDT + Queue)    │ │
│  └──────────┬──────────────┘ │
└─────────────┼────────────────┘
              │ HTTP (JSON)
┌─────────────▼────────────────┐
│         Server (Node.js)      │
│  ┌──────────┐ ┌────────────┐ │
│  │ SvelteKit│ │   SQLite   │ │
│  │   API    │◄│ (Drizzle)  │ │
│  └──────────┘ └────────────┘ │
└──────────────────────────────┘

Data Flow

Read Path

  1. User opens app
  2. Svelte stores read from IndexedDB (instant)
  3. Background sync pulls latest from server
  4. If newer data found, IDB is updated, stores react

Write Path

  1. User creates/edits note
  2. Change written to IndexedDB immediately
  3. Change added to sync queue
  4. Background sync pushes queue to server
  5. Server applies changes with LWW resolution

Conflict Resolution (LWW)

For a single-user app with multiple devices:

  • Each note has an updatedAt timestamp and version number
  • When merging: newer timestamp wins
  • If timestamps match: higher version wins
  • If both match: local version is preferred

Attachment Data Flow

  1. User drops/picks image in NoteEditor
  2. Client optimizes: resize ≤1920px, compress to WebP, generate 200px thumbnail
  3. Online: POST optimized + thumbnail to /api/notes/{id}/attachments → server saves to data/attachments/ + DB
  4. Offline: store blobs in IDB pendingAttachments store → display via URL.createObjectURL() → on reconnect, sync pushes to server
  5. Display: NoteCard uses &thumb=1 URL (200px WebP), editor shows full-size
  6. SW Caching: CacheFirst rule caches attachment URLs — previously-viewed images available offline

Tech Stack Rationale

SvelteKit

  • Smallest bundle size of major frameworks
  • Built-in SSR, routing, API routes
  • Svelte 5 runes for reactive state
  • Native service worker support via @vite-pwa/sveltekit

SQLite + Drizzle

  • Zero-config database (single file)
  • WAL mode for concurrent reads
  • Drizzle provides type-safe queries without ORM overhead
  • Perfect for single-user self-hosted apps

IndexedDB (idb)

  • Browser-native storage (no size limits like localStorage)
  • Async API doesn't block main thread
  • idb library provides Promise-based wrapper
  • Survives browser restarts

Tailwind CSS 4

  • Utility-first, tree-shakeable
  • Built-in dark mode support (dark: variants)
  • New v4 uses Vite plugin (faster builds)

lowlight (highlight.js)

Code blocks are highlighted by one shared lowlight instance (src/lib/utils/highlight.ts) that feeds both the TipTap editor (@tiptap/extension-code-block-lowlight, live decorations) and the markdown-it renderer used for card previews and the share page, so a fence looks the same everywhere. Only the highlight.js "common" grammar set is bundled. Language auto-detection is deliberately disabled: it misreads note-sized snippets too often, so an unlabelled fence renders as plain text and the language comes from the fence info string or the toolbar picker. Token colours are the six --hl-* variables in src/app.css; a unit test asserts AA contrast against --code-bg in both themes.

Argon2

  • Memory-hard password hashing (resists GPU attacks)
  • See AUTH.md for authentication details

Database Schema

See src/lib/server/db/schema.ts for the complete Drizzle schema.

Key tables:

  • users - User accounts (password hash, password_login_enabled, OAuth provider link, role)
  • sessions - Auth sessions (30-day TTL)
  • notes - Core note data with version tracking
  • tags - Unique tag names
  • note_tags - Many-to-many note ↔ tag
  • attachments - Image file metadata (path + thumbnailPath)
  • api_keys - MCP API keys (SHA-256 hashed, prefix for display)
  • note_collaborators - Sharing relationships (noteId + userId + addedBy)
  • note_user_state - Per-user pin/archive/sortOrder for shared notes
  • shared_notes - Public share links (noteId → token, 1:1 with cascade delete)
  • sync_log - Sync operation history

Nested Checklist Serialization

Checklist items are stored as plain text in the note content field, one item per line. Child items use a 2-space indent prefix:

- [ ] Parent item
  - [ ] Child item
  - [x] Checked child item
- [x] Another parent
  • A line starting with - [ ] or - [x] (2 spaces) is parsed as a child of the preceding top-level item.
  • The parentId field on ChecklistItem is runtime-only — it is derived from position during parsing and never stored explicitly.
  • This format is fully backward compatible: flat checklists (no indented lines) parse identically to before.

Collaboration Access Control

Notes support sharing with other users on the same instance:

  • Owner: Full control (edit, trash, delete, share/unshare)
  • Collaborator: Can edit content, color, checklist mode; has independent pin/archive/sortOrder state via note_user_state
  • Access checks use canAccessNote() / requireNoteAccess() / requireNoteOwnership() helpers
  • note_user_state stores per-user view state (pinned, archived, sortOrder) — collaborators see their own organization
  • When owner trashes a note, it vanishes for all collaborators
  • Collaborators can leave (remove themselves) from shared notes

MCP Server

Crumbs includes a built-in Model Context Protocol server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically.

Architecture

  • Endpoint: POST /api/mcp (Streamable HTTP transport)
  • Auth: Bearer token via API keys (generated in Settings)
  • Sessions: Stateful per MCP spec — session ID in mcp-session-id header
  • Transport: WebStandardStreamableHTTPServerTransport (works with SvelteKit's Web Standard Request/Response)

Shared Logic

MCP tool handlers and REST API routes both call the same service layer (src/lib/server/notes-service.ts). This avoids duplicating business logic:

REST API routes ──┐
                  ├──► notes-service.ts ──► Drizzle DB
MCP tool handlers ┘

Tools (14)

list_notes, get_note, create_note, update_note, trash_note, restore_note, archive_note, unarchive_note, delete_note, search_notes, list_tags, pin_note, reorder_notes, upload_image

Dark Mode

Theme switching uses CSS variable overrides on the <html> element:

  • CSS: [data-theme="dark"] attribute on <html> overrides all --bg-*, --text-*, --border-*, and --primary-* variables defined in src/app.css
  • Persistence: preference stored as theme in the existing userPreferences key-value system (values: "system" | "light" | "dark")
  • FOUC prevention: a blocking inline <script> in app.html reads the preference from localStorage and sets data-theme before any content renders
  • Reactivity: a $effect in the root +layout.svelte watches the theme store and updates document.documentElement.dataset.theme on change
  • Note card colors: getNoteColor() in src/lib/utils/colors.ts accepts a dark boolean and returns the appropriate surface variant per theme. Picker swatches use getNoteChip() instead — in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker draws from a separate vivid NOTE_CHIPS_DARK map.

File Organization

src/lib/server/    # Server-only code (DB, auth, attachments, notes-service)
src/lib/server/mcp/ # MCP server + tool definitions
src/lib/sync/      # Sync engine (shared types, server + client implementations)
src/lib/stores/    # Svelte reactive stores (notes, theme, auth, search)
src/lib/components/ # Svelte UI components
src/lib/utils/     # Pure utility functions (markdown, tags, colors)
src/lib/types/     # TypeScript type definitions
src/routes/api/    # REST API + MCP endpoints
src/routes/        # Page routes (SvelteKit file-based routing)