Meridian is a local-first, privacy-focused productivity system built for macOS. It combines a spatial infinite canvas editor with structured daily tracking, designed for long-term reliability and "calm" computing.
Meridian follows a strict Main-Renderer separation pattern typical of secure Electron applications. The architecture prioritizes data integrity (ACID compliant SQLite) and UI responsiveness (React 18 concurrent features).
flowchart TD
UI[Renderer - React]
PRELOAD[Preload Bridge]
MAIN[Main Process]
DB[(SQLite DB)]
FS[File System]
UI -->|IPC Request| PRELOAD
PRELOAD -->|Validated Call| MAIN
MAIN --> DB
MAIN --> FS
MAIN -->|IPC Response| PRELOAD
PRELOAD --> UI
- Renderer (
/src): Handles all presentation logic. UsesZustandfor state management to avoid prop-drilling in the complex canvas component tree. - Main Process (
/electron): The source of truth. Manages the database connection, file system access, and native window management. - Preload (
/electron/preload.ts): A security sandbox that exposes specific API methods to the renderer, preventing direct Node.js access from the UI.
All data mutations occur via strictly typed IPC channels. We use the Request-Response pattern (ipcRenderer.invoke / ipcMain.handle) to ensure the UI waits for confirmation of persistence before updating its optimistic state (or handling errors).
sequenceDiagram
participant UI
participant Preload
participant Main
participant DB
UI->>Preload: saveNote(content)
Preload->>Main: ipc.invoke("save-note")
Main->>Main: validateInput()
Main->>DB: INSERT/UPDATE note
DB-->>Main: success
Main-->>Preload: result
Preload-->>UI: success response
The core of Meridian is its spatial document editor (DocumentEditor), which supports mixed-media layout (text flow + floating shapes).
flowchart TD
Edit[User Edits Block]
Dirty[Mark Dirty State]
Recalc[Recalculate Layout]
Collision[Check Shape Collision]
Reflow[Adjust Text Position]
Render[Re-render Canvas]
Edit --> Dirty --> Recalc --> Collision --> Reflow --> Render
- State Change: User adds a shape or resizes the text window.
- Intersection Calculation (
useTextWrapping.ts):- The engine calculates the bounding box of every floating object.
- It identifies which text "regions" (paragraphs/lines) intersect with these boxes.
- It computes
WrapRegiondescriptors (left/right insets) for the text stream.
- Layout Reflow (
TextDocument.tsx):- The text editor receives new wrapping regions.
- It dynamically injects spacer elements or applies margins to text blocks to "flow" text around the shapes.
- Overflow Check:
- If text pushes beyond the page bottom, the
PageContainerdetects overflow. - New pages are automatically created, and content is migrated to the next page.
- If text pushes beyond the page bottom, the
- Paint: React commits the changes to the DOM.
To minimize database writes, the editor uses a "dirty" state tracking mechanism:
- Input: Sets
isDirty = true. - Blur/Pause: Triggers save only if
isDirtyis true. - Save Complete: Sets
isDirty = false.
Persistence is handled by better-sqlite3 in WAL (Write-Ahead Logging) mode for high concurrency and performance.
erDiagram
DAYS ||--o{ DAILY_TASKS : contains
DAYS ||--o{ HABIT_COMPLETIONS : tracks
HABITS ||--o{ HABIT_COMPLETIONS : logs
DATE_RANGES ||--o{ RANGE_TASKS : includes
DAYS ||--o{ DAY_REFLECTIONS : stores
NOTES {
string id
json content
datetime updated_at
}
Problem: Managing an unbounded coordinate space where users can drag shapes anywhere without causing performance degradation or "lost" elements. Approach:
- Coordinate System: We implemented a unified page-relative coordinate system. Instead of true infinite scrolling (which complicates printing/export), we use a "Virtual Page Stack".
- Bounds Management: Shapes are children of the
PageStack. Coordinates are calculated relative to the document, but rendered relative to current viewport. - Optimization: To prevent runaway DOM expansion, we use React virtualization techniques — only rendering pages currently near the viewport.
- Reflow Stability: Naïve attempts to resize the canvas caused jitter. We moved to a fixed-width A4 model where only height expands, ensuring deterministic text flow at all times.
The Challenge: The web platform (DOM) has no native API for "wrap text around this arbitrary absolute div". CSS float only works for elements in the same flow context.
Failed Solution: Initially, we tried manual line-breaking by calculating character widths. This was slow, fragile, and broke with different fonts.
Final Solution: We built a geometric collision engine (useTextWrapping.ts).
- It projects 2D shape bounds onto the 1D text column.
- It generates specific "exclusion rects" (
WrapRegion). - The text renderer receives these rects and dynamically applies
margin-left/margin-rightto block-level elements that overlap. Trade-off: Text wrapping is block-level (paragraph/div), not per-line. This is a conscious decision to maintain performance (60fps) over typographic perfection.
Security First: In Electron, the Renderer is untrusted (it runs user content). The Main process is privileged.
- Isolation: The Renderer has zero direct access to the
fsorsqlitemodules. It cannot execute arbitrary SQL. - Bridge: The
preload.tscontext bridge only exposes specific methods (saveNote,getDays). - Validation: The Main process strictly validates all inputs.
- Type Checking: Runtime checks ensure payloads match expected schemas.
- Sanitization: JSON content (Notes) is parsed and structure-checked. HTML strings are sanitized via
DOMPurifybefore storage/display to prevent XSS.
Philosophy: Additive migrations only.
- Reasoning: We never
DROP COLUMNor modify existing data types. This ensures that:- New versions of the app can safely open old databases.
- If a migration fails, the data remains intact (though potentially inaccessible to new features).
- Users can downgrade (within reason) without data corruption.
- Implementation: On app startup,
database.tschecksPRAGMA table_info. If a column (e.g.,priorityon tasks) is missing, it runs anALTER TABLEstatement. This is simple, robust, and zero-dependency.
- Process Boundaries are Contracts: Treating the IPC layer as a strict API contract (like a REST API) prevented spaghetti code. Refactoring the backend didn't break the frontend because the "API" remained stable.
- State Separation: Trying to sync complex canvas state via prop-drilling in React was a mistake. Moving to
Zustandstores for localized component updates was the single biggest performance win. - Documentation as Infrastructure: Writing this documentation forced us to clarify the architecture. Several inconsistencies were found and fixed while writing the "System Architecture" section.
- Early Architecture Decisions: Choosing
better-sqlite3overSequelize/TypeORMpaid off. The raw SQL control provided predictable performance and simpler migrations without the "black box" overhead of an ORM.
We chose a synchronous-interface SQLite driver because it runs in the Main process (Node.js). Being synchronous simplifies the code (no callback hell) and is actually faster for the scale of local data we process (< 100 queries/sec) because it avoids the overhead of context switching between JS and native C++ threads for every small query.
We implement "Blur-to-Save" (save on focus loss) rather than continuous autosave during typing. This prevents database thrashing and race conditions while ensuring data is persisted effectively when the user pauses or switches contexts.
- Manual Regression Testing: Rigorous manual testing across "Day View", "Mind Canvas", and "Memory" stats before every release.
- Migration Validation: Startup logic specifically tests for schema mismatches against previous versions.
- Unit Tests (Vitest): Planned coverage for
useTextWrappinggeometric logic and Zustand store reducers. - Integration Tests: Planned IPC handler tests using an in-memory SQLite DB to verify validation barriers.
- Canvas Stress Tests: Edge-case testing for extreme zoom levels (10% - 300%) and deep page stacks (10+ pages).
The goal is fully automated releases via GitHub Actions.
- Lint & Type Check (Current)
- Runs
eslintandtscon every commit.
- Runs
- Build Validation
npm run build:macruns on PRs to ensure the build pipeline isn't broken.
- Auto-Release (Draft)
- Trigger: Tag push
v*. - Action: Build
.dmg, draft GitHub Release, upload artifacts.
- Trigger: Tag push
- Code Signing (Future)
- Integrate Apple Developer ID certificate into CI secrets for notarized builds (required for auto-updates).
app/
├── electron/ # Main Process (Node.js context)
│ ├── main.ts # Entry point, Window manager, IPC Handlers
│ ├── database.ts # SQLite schema & migrations
│ └── preload.ts # Context Bridge
│
├── src/ # Renderer Process (React context)
│ ├── components/
│ │ └── notes/ # Spatial Editor Engine
│ │ ├── document/ # Canvas & Text Logic
│ │ └── ...
│ ├── store/ # State Management
│ └── types.ts # TypeScript Definitions
│
└── release/ # Build Artifacts
# Install dependencies
npm install
# Development (HMR)
npm run electron:dev
# Production Build (Mac Universal)
npm run build:mac