Architecture Guide

How FINAL|FINAL Works Under the Hood

A macOS writing app built with a native shell, two web editors, and a SQLite database. Here's how all the pieces fit together -- no programming experience required.

Scroll to begin
01

Meet the Cast

Five specialized components work together to power FINAL|FINAL. Here's who does what.

The Five Actors

Think of FINAL|FINAL like a theater production. Each component has a single, well-defined role. When something breaks, knowing who's responsible tells you where to look.

🎭
SwiftUI Shell -- stage manager

The native macOS frame: window, toolbar, outline sidebar, status bar. Built with SwiftUI, it orchestrates everything the user sees.

Milkdown -- WYSIWYG artist

The formatted text editor. Built on ProseMirror, it shows bold as bold, headings as large text. Hides all the markdown symbols. Runs as a web page inside a WKWebView.

🔧
CodeMirror -- source mechanic

The raw markdown editor. Shows the actual symbols (## Heading, **bold**) with syntax highlighting. Also runs inside a WKWebView.

📦
BlockSyncService -- translator

The courier between editors and database. Every 2 seconds it asks "got any changes?" and writes them to storage. Speaks both JavaScript and Swift.

🗃
SQLite Database -- the vault

The single source of truth. Every paragraph, heading, and list is a row in a database table. Nothing is "real" until it's saved here. The sidebar watches the database and re-renders automatically when something changes.

💡
Separation of Concerns

Each component has exactly one job. Editor handles text. Sync handles communication. Database handles storage. Sidebar handles display. This pattern -- splitting responsibilities into focused roles -- is one of the most important ideas in software engineering.

How They're Arranged

Click any component to see what it does:

Native (Swift)

🎭
ContentView
📄
OutlineSidebar
🧠
EditorViewState

Web Editors (WKWebView)

Milkdown
🔧
CodeMirror

Data Layer

📦
BlockSyncService
🗃
ProjectDatabase
Click any component to learn what it does

A Conversation Between Components

Here's what happens when you type a new heading. Watch the components talk to each other:

💬 Component Chat -- "User types a heading"
0 / 8 messages
02

How They Talk

The five actors speak two different languages. Here's the bridge that connects them.

Two Languages, One App

The outer shell (toolbar, sidebar) is written in Swift. The inner editors are written in JavaScript. They run in completely different worlds -- like two diplomats who need a translator.

The bridge is window.FinalFinal -- a shared API that both sides use. Swift calls JavaScript functions to ask questions. JavaScript calls Swift message handlers to send updates back.

The API Menu

Here's what Swift can ask the editors to do:

setContent(md)Load new content into the editor
getContent()Get the current markdown back
hasBlockChanges()"Any changes since I last checked?"
getBlockChanges()Give me the details: inserts, updates, deletes
confirmBlockIds(map)"Your temp ID is now this permanent UUID"
setFocusMode(on)Toggle paragraph dimming for focus
setTheme(css)Apply new colors and fonts
scrollToLine(n)Scroll to a specific line

Push + Poll

Two communication patterns run simultaneously, each optimized for different kinds of data:

Push (instant, ~50ms)

When you type, the editor immediately sends the new content to Swift via postMessage. Used for content that must be saved right away -- you don't want to lose keystrokes.

🕐

Poll (periodic, every 2s)

BlockSyncService wakes up on a timer and asks for structural changes -- new blocks created, blocks deleted, blocks reordered. Changes accumulate between polls, so they're processed in efficient batches.

Why both? Push gives speed for the content itself. Polling gives efficiency for structural metadata. It's like a newsroom: breaking news goes out immediately (push), but the daily summary is compiled on a schedule (poll).

See It In Action

Click "Next Step" to trace what happens when you type a heading:

Ed
Milkdown
Sy
BlockSync
DB
SQLite
Si
Sidebar
Click "Next Step" to begin
Step 0 / 6

The Polling Code

BlockSyncService uses a repeating 2-second timer to check for structural changes. Here's the function that starts this loop -- it creates a timer that calls pollBlockChanges() on every tick:

CODE
func startPolling() {
    stopPolling()
    pollTimer = Timer.scheduledTimer(
        withTimeInterval: pollInterval,
        repeats: true
    ) { [weak self] _ in
        Task { @MainActor in
            await self?.pollBlockChanges()
        }
    }
}
PLAIN ENGLISH

Stop any existing timer first -- avoid stacking multiple timers on top of each other

Create a new timer that fires every 2 seconds and keeps repeating

Use "weak self" so neither the timer nor the service keeps the other alive forever (prevents a memory leak)

Each tick: run on the main thread and ask the editor for block changes

💡
Why 2 Seconds?

The editor generates dozens of tiny changes per keystroke. Polling every 2 seconds batches them together -- like collecting mail once a day instead of checking the mailbox after every letter.

03

The Vault

When data arrives through the bridge, it's stored in a SQLite database. Here's how.

A Portable Package

Your entire project is a single macOS package -- a folder that appears as one file in Finder. You can back it up or share it just by copying:

📁 MyBook.ff/ Your project (appears as a single file in Finder)
📄 content.sqliteThe SQLite database -- ALL your writing lives here
📁 media/Images and attachments
📁 references/Citation data for offline bibliography

Everything Is a Block

Your document isn't stored as one big blob of text. Instead, imagine a spreadsheet where each row is a structural element -- a paragraph, a heading, a list, a code block. Each row has a unique ID (a permanent fingerprint), a sort order (a decimal number that determines position), a type, and the actual content.

Here's the blueprint for that spreadsheet -- the database table that holds every block:

DATABASE SCHEMA
CREATE TABLE block (
    id         TEXT PRIMARY KEY,
    sortOrder  DOUBLE NOT NULL,
    blockType  TEXT NOT NULL,
    textContent  TEXT NOT NULL,
    markdownFragment TEXT NOT NULL,
    headingLevel INTEGER,
    status     TEXT,
    wordCount  INTEGER DEFAULT 0
);
WHAT EACH COLUMN IS FOR

id -- a unique fingerprint (UUID) so the app can track this block even if you edit it

sortOrder -- determines position in the document. 5.0 comes before 6.0. Decimals allow insertion without renumbering

blockType -- tells the app how to render it: "heading" gets big text, "bulletList" gets bullets, "paragraph" gets plain text

textContent -- plain text stripped of formatting. Used for fast full-text search and accurate word counting

markdownFragment -- the real content with all formatting symbols (## Heading, **bold**). Used to render the document

headingLevel -- 1 through 6 for headings (# through ######), empty for everything else

status -- workflow tracking: "next", "writing", "waiting", "review", or "final"

wordCount -- how many words in this block

The Fractional Sort Order Trick

How do you insert a new paragraph between two existing ones without renumbering everything? Use decimal sort orders and pick a number in between:

5.0
## Introduction

Heading, sort order 5.0 -- stays put

5.5
New paragraph inserted later

Gets 5.5 -- halfway between 5.0 and 6.0. No other blocks need to move!

6.0
Existing next paragraph

Sort order 6.0 -- unchanged

💡
This Also Powers Drag-and-Drop

When you drag a section in the sidebar, the app just updates the sort order numbers on the affected blocks. The text stays in the database untouched -- only the position values change. With thousands of blocks, this is much faster than renumbering every row.

04

Two Editors, One Document

The same content displayed two different ways -- and the state machine that keeps them from colliding.

Same Song, Two Instruments

Milkdown and CodeMirror both display the exact same content from the vault, but in completely different formats. Press Cmd+/ to toggle between them. The database view below is what's actually stored on disk -- both editors are just different lenses on the same data.

Literature Review

The study of vibe coding has shown that non-technical users can build sophisticated software (Smith, 2024).

Previous research suggests that understanding architecture is the key skill.

## Literature Review

The study of **vibe coding** has shown that non-technical users can build sophisticated software [@smith2024].

Previous research suggests that *understanding architecture* is the key skill.
idsorttypecontent
a1b2..5.0heading## Literature Review
c3d4..6.0paragraphThe study of **vibe coding**...
e5f6..7.0paragraphPrevious research suggests...

WYSIWYG mode: formatted text. Bold is bold, headings are large. Markdown symbols hidden.

Tracking Identity Across Editors

Both editors need to know which block is which so changes sync to the right database row. They solve this differently because they have fundamentally different document models:

Milkdown: Node Attributes

ProseMirror represents documents as a tree of nodes. Each node carries invisible metadata including a blockId. You can't see it in the text -- it's like an invisible barcode.

🔧

CodeMirror: Comment Anchors

Plain text editor -- no concept of "nodes." Block IDs are embedded as HTML comments: <!-- @bid:UUID -->

When you switch editors, the app translates between these systems: extracting node attributes into comment anchors, or vice versa.

The Traffic Controller

Switching editors, zooming into a section, and dragging to reorder all involve replacing the editor's content. If two happen simultaneously, data gets corrupted. The app uses a state machine -- only one transition at a time:

idle

Normal operation. Polling active. Everything calm.

editorTransition

Cmd+/ pressed. Saving cursor, swapping anchors, restoring position. Polling paused.

🔍
zoomTransition

User double-clicked a section. Loading subset of blocks. Polling paused.

🔄
dragReorder

Dragging sections in sidebar. Sync suppressed to prevent races.

💡
The 5-Second Watchdog

What if a transition gets stuck? Every non-idle state has a 5-second watchdog timer. If the app hasn't returned to "idle" in time, the watchdog forces a reset -- like a dead man's switch on a train. Prevents permanent freezes.

05

The Outside World

FINAL|FINAL connects to three external tools that extend its capabilities.

The Supply Chain

📚
Zotero + Better BibTeX

Your citation library. The app searches it via a local API at localhost:23119. Everything on your computer -- no internet required.

📄
Pandoc

Universal document converter. Takes your markdown and produces Word (.docx), PDF, or ODT. Like a professional typesetter on call.

📝
LanguageTool

AI-powered grammar checker. Can run as a free online service or a local server for privacy. Goes beyond macOS built-in spellcheck.

The Citation Journey

When you type /cite and select a paper:

1

You type
/cite

2

App queries
Zotero

3

You pick
a source

4

Citation key
inserted

5

Bibliography
auto-generated

Checking Zotero's Availability

Before citations work, the app sends a quick "are you there?" request to Zotero's local server. Here's the code -- it defines a thread-safe checker that connects to Zotero on your own machine and sends a minimal test request:

CODE
actor ZoteroChecker {
    private let statusEndpoint =
        "http://127.0.0.1:23119/
         better-bibtex/json-rpc"

    func check() async
        -> ZoteroStatus {
        // Send a minimal request
        // to see if Zotero responds
    }
}
PLAIN ENGLISH

Define this as an actor -- a thread-safe worker (one request at a time, no collisions)

The address to check: on YOUR computer (127.0.0.1), port 23119, at Better BibTeX's endpoint

A function that checks if Zotero is running -- sends a small test request, like knocking on a door to see if anyone's home

Returns a status: running, not running, missing plugin, or timed out

06

The Big Picture

Why it was built this way, and how to find your way when things break.

Architecture Decisions

Every choice was a tradeoff. Understanding why things were built this way is what lets you steer AI coding tools confidently:

🗃

SQLite-First

Why: Eliminates file watching, manifest sync, undo coordination. Tradeoff: Users can't edit files directly in Finder.

🌐

Web Editors in Native App

Why: ProseMirror and CodeMirror are battle-tested by millions. Tradeoff: Bridge complexity between Swift and JS.

Block-Based Content

Why: Surgical updates, easy reordering, per-block metadata. Tradeoff: More complex sync than one big string.

🛠

State Machine

Why: Prevents race conditions during zoom, editor switching, reordering. Tradeoff: More upfront design work.

Debugging Map

When something breaks, here's where to look:

Sidebar not updatingCheck BlockSyncService polling + database ValueObservation + contentState
Content lost on switchWas a flush completed before the editor swap? Check state machine.
Citations brokenIs Zotero running? Better BibTeX installed? Check port 23119.
Export failsIs Pandoc installed? Check PandocLocator. Valid markdown?
Editor looks staleClear Derived Data. Did the web build (pnpm build) succeed?
Zoom stuck5-second watchdog in EditorViewState. Is contentState returning to idle?

That's the Full Picture

FINAL|FINAL combines a hybrid native/web architecture, database-first storage, block-based content, state machines, and external integrations into a cohesive writing tool.

🎓
Useful Vocabulary

When working with an AI coding assistant on this codebase, you can now say things like: "Put this in the sync service, not the view layer," or "Use a state machine to prevent race conditions," or "Store this in the database and observe it reactively."