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
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
- User opens app
- Svelte stores read from IndexedDB (instant)
- Background sync pulls latest from server
- If newer data found, IDB is updated, stores react
Write Path
- User creates/edits note
- Change written to IndexedDB immediately
- Change added to sync queue
- Background sync pushes queue to server
- Server applies changes with LWW resolution
Conflict Resolution (LWW)
For a single-user app with multiple devices:
- Each note has an
updatedAttimestamp andversionnumber - When merging: newer timestamp wins
- If timestamps match: higher version wins
- If both match: local version is preferred
Attachment Data Flow
- User drops/picks image in NoteEditor
- Client optimizes: resize ≤1920px, compress to WebP, generate 200px thumbnail
- Online: POST optimized + thumbnail to
/api/notes/{id}/attachments→ server saves todata/attachments/+ DB - Offline: store blobs in IDB
pendingAttachmentsstore → display viaURL.createObjectURL()→ on reconnect, sync pushes to server - Display: NoteCard uses
&thumb=1URL (200px WebP), editor shows full-size - SW Caching:
CacheFirstrule 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
idblibrary 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 trackingtags- Unique tag namesnote_tags- Many-to-many note ↔ tagattachments- 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 notesshared_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
parentIdfield onChecklistItemis 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_statestores 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-idheader - 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 insrc/app.css - Persistence: preference stored as
themein the existinguserPreferenceskey-value system (values:"system"|"light"|"dark") - FOUC prevention: a blocking inline
<script>inapp.htmlreads the preference from localStorage and setsdata-themebefore any content renders - Reactivity: a
$effectin the root+layout.sveltewatches the theme store and updatesdocument.documentElement.dataset.themeon change - Note card colors:
getNoteColor()insrc/lib/utils/colors.tsaccepts adarkboolean and returns the appropriate surface variant per theme. Picker swatches usegetNoteChip()instead — in dark mode the card surfaces are too dark to be identifiable as small circles, so the picker draws from a separate vividNOTE_CHIPS_DARKmap.
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)