**Spec version:** 1.0 · **Audience:** Claude Code (implementation agent)
## 1. Overview
Tefter is a personal, self-hosted note-taking system that replicates the Notational Velocity (NV) interaction model — a single search/create field, instant incremental filtering, and modeless keyboard-driven editing — with Markdown support and offline-first sync across Linux, macOS, Windows, Android, and web.
It replaces Simplenote + Notational Velocity for a single user who hosts their own data.
### Goals
- Pixel-faithful NV *interaction model* (not pixel-faithful visuals): one omnibar that searches AND creates, zero modal dialogs, zero save buttons, everything reachable by keyboard.
- Markdown editing with optional preview.
- Offline-first on every client; sync when connectivity returns.
- Single-user, single-server, trivial deployment: **one Go binary + one SQLite file**.
- Import of an existing Simplenote export archive.
**Key decision — one frontend, three shells.** The web app is the product. It runs:
1. In a browser (PWA, served by `tefterd`).
2. Installed on Android (PWA install prompt).
3. Inside a Wails v2 webview window on desktop (frontend bundle embedded in the desktop binary; works fully offline, no `tefterd` needed to launch).
Offline storage and the sync engine live in the **frontend** (TypeScript + IndexedDB), so the exact same offline/sync code runs on all five platforms. The Wails Go layer is a thin shell: window, tray, global shortcut, single-instance lock.
### Components / repo layout
```
tefter/
├─ server/ # tefterd
│ ├─ main.go
│ ├─ api/ # HTTP handlers
│ ├─ store/ # SQLite access layer
│ ├─ importer/ # Simplenote import
│ └─ webdist/ # go:embed of ../frontend/dist
├─ frontend/ # TypeScript + Svelte, Vite build
│ ├─ src/
│ │ ├─ ui/ # omnibar, list, editor, preview
│ │ ├─ db/ # IndexedDB local store
│ │ └─ sync/ # sync engine
│ └─ dist/ # built bundle (embedded by server & desktop)
├─ desktop/ # Wails v2 app
│ ├─ main.go
│ └─ wails.json
├─ Makefile
└─ .github/workflows/release.yml
```
### Tech choices (fixed — do not substitute)
- **Server:** Go ≥1.22, stdlib `net/http` (Go 1.22 routing), `modernc.org/sqlite` (pure Go, no CGO → painless cross-compilation and static binaries).
- **Title is derived**, never stored: first non-empty line of `content`, markdown heading markers stripped for display.
-`version` is a single global monotonically increasing counter (like a Lamport clock per server). Every accepted write bumps the global counter and stamps the note. This makes "give me everything changed since cursor X" trivial.
- Tombstones are kept forever (personal scale; millions of notes are not expected). A `tefterd compact` subcommand may purge tombstones older than N days.
- **Version history:** whenever an accepted push overwrites an existing note (fast-forward or edit-over-tombstone), the superseded state is copied into `note_history`, capped at the newest 50 revisions per note. Conflict pushes touch nothing, so they record nothing. `compact` purges history rows of notes that no longer exist. Rollback is client-side: fetch a revision, apply its content as a normal edit.
Object store `meta`: `cursor` (last server version pulled), `serverUrl`, `token`.
## 4. Sync Protocol
Design: **pull-then-push, last-write-wins with conflict copies** (Simplenote-style). Single user across a handful of devices; CRDTs are overkill. Sync must be safe to interrupt at any point.
### Endpoints (all under `/api/v1`, JSON, `Authorization: Bearer <token>`)
| Method | Path | Purpose |
|---|---|---|
| GET | `/changes?since=<cursor>&limit=500` | Pull notes with `version > cursor`, ordered by version. Returns `{notes: [...], cursor: <max version returned>, more: bool}` |
| POST | `/notes/batch` | Push local changes. Body: `{notes: [{id, content, tags, created_at, modified_at, deleted, baseVersion}]}` |
| GET | `/notes/{id}/history` | Superseded revisions of a note, newest first: `{revisions: [{note_id, version, content, tags, modified_at, replaced_at}]}` (empty list for unknown ids) |
| POST | `/import/simplenote` | Multipart upload of Simplenote export zip (also available as CLI) |
### Push conflict rule (server-side, per note)
1. If note id unknown → insert, assign new version. **Accepted.**
2. If `baseVersion == current version` → clean fast-forward. Update, assign new version. **Accepted.**
3. Else **conflict**: another device changed the note since this client last pulled.
- Keep the server's current content in the original note.
- Apply the incoming content as a **new note** with a new UUID, content prefixed by nothing (content unchanged), and tag `conflict` added; its first line gets ` (conflicted copy YYYY-MM-DD HH:MM)` appended.
- Response marks the note `{id, status: "conflict", conflictCopyId: ...}`.
4. Deletion conflicts: delete vs edit → edit wins (tombstone is overwritten by the edit as a conflict copy is NOT created; the edited version simply survives).
### Client sync loop
1. On startup, on network regain, after 3 s of edit inactivity, and every 60 s: run sync if online.
2.**Pull**: page through `/changes` from local `cursor`. For each incoming note: if local copy is not dirty → overwrite; if dirty and incoming version > baseVersion → keep local dirty copy, remember conflict will be resolved by server at push. Update `cursor`.
3.**Push**: send all dirty notes in one batch. On `accepted`, clear dirty, set `baseVersion` to returned version. On `conflict`, replace local with server truth and add the returned conflict copy on next pull.
4. All steps idempotent; a crash mid-sync must never lose an edit (edits are persisted to IndexedDB on every debounced change, before any network activity).
### Auth
- Single bearer token, generated at first server start (`tefterd init` prints it), stored hashed (SHA-256) in `meta`. Rotate with `tefterd token rotate`.
- No accounts, no sessions, no OAuth. TLS is delegated to the user's reverse proxy; `tefterd` listens on localhost/HTTP by default (`--listen :8420`).
## 5. UI Specification — the Notational Velocity Model
This section is the heart of the product. The NV model must be reproduced exactly.
### Layout
```
┌──────────────────────────────────────────────┐
│ [ omnibar: Search or Create ] │ ← always the same field
│ editor (CodeMirror, markdown) │ ← or rendered preview
│ │
└──────────────────────────────────────────────┘
```
- Default: horizontal split (list above editor). Setting for vertical split (list left, editor right). On narrow screens (<640 px, i.e., Android), list and editor become two stacked views with back navigation.
- No toolbar. No save button. No menus except a small `⋯` for settings/sync status.
- A tiny status dot next to the omnibar: green = synced, yellow = syncing, gray = offline with pending changes. Clicking shows last sync time and pending count.
### Omnibar behavior (exact)
1. Focus is in the omnibar when the app opens.
2. Typing filters the list **on every keystroke** (target: <10 ms for 10k notes — filter in memory; all note metadata + content is loaded into RAM from IndexedDB at startup).
3. Ranking: exact title match > title prefix > title contains > body contains. Secondary sort: `modified_at` desc. Empty query shows all notes by `modified_at` desc.
4. Matching is case-insensitive, diacritic-insensitive (č/c, š/s, ž/z fold together), across space-separated terms (AND semantics).
5.**Enter** in the omnibar:
- If a list row is highlighted → open that note in the editor (focus editor, caret at end).
- If the query matches nothing → **create a note whose first line is the query text**, open it in the editor.
6.**↓/↑** from the omnibar moves the list selection without leaving the field; the editor live-previews the selected note (NV behavior).
7.**Esc**: clear omnibar, deselect, focus omnibar. Pressing Esc in the editor returns focus to the omnibar (query preserved).
8. Search term highlighting in list rows and in the editor.
### Keyboard shortcuts (Cmd on macOS, Ctrl elsewhere)
| Cmd/Ctrl+I | Note info (created / modified / last synced) + version history panel; a revision can be previewed and restored (restore = normal edit, non-destructive) |
- CodeMirror 6, markdown mode, light syntax styling only (bold headings, dim syntax marks). Monospace or user-set font. No WYSIWYG.
- **Autosave**: debounce 400 ms after last keystroke → write to IndexedDB, mark dirty. Never a save action.
- Preview: `Cmd/Ctrl+Shift+P` swaps the editor pane for rendered markdown (`marked` + `DOMPurify`, GFM: tables, task lists, fenced code, strikethrough). Same shortcut toggles back. Links open in system browser/new tab. Internal `[[Note Title]]` wiki-links open/create that note (NV Alt behavior — nice-to-have, milestone 4).
### Visual style
- Minimal, native-feeling: system font stack for UI chrome, generous line height in the list, thin 1 px separators. Light and dark theme following OS preference. No animation except the undo toast.
## 6. Simplenote Import
Input: the official Simplenote export zip (contains `source/notes.json` with `activeNotes` and `trashedNotes`, each having `id, content, creationDate, lastModified, tags, markdown` fields — verify actual field names against a real export at implementation time and adapt).
- CLI: `tefterd import simplenote /path/to/export.zip` (server-side, direct to SQLite).
- Mapping: content → content; creationDate/lastModified (ISO 8601) → unix ms; tags → tags; trashedNotes → imported with `deleted=1` tombstones (recoverable via a future trash view; v1 just keeps them synced-invisible).
- Idempotent: re-importing the same zip must not duplicate notes (dedupe by Simplenote id stored in a `source_id` column, or by exact content+creation date hash).
- Print a summary: imported N, skipped M duplicates, T trashed.
## 7. Desktop Shell (Wails v2)
- One window containing the frontend bundle (same `frontend/dist` embedded via Wails assets).
- Single-instance lock; second launch focuses the existing window.
- Global OS shortcut (default Cmd/Ctrl+Shift+Space, configurable) shows/hides the window and focuses the omnibar — the classic NV "always at hand" flow.
- Closing the window hides to tray (tray icon: open, sync now, quit). Setting to quit-on-close instead.
- First-run screen: server URL + token fields, "work locally only" option (sync can be configured later).
- Storage is the webview's IndexedDB — the desktop app is fully functional offline and without any server.
## 8. PWA Requirements (web + Android)
-`manifest.json` (standalone display, icons 192/512, theme color) + service worker: precache the app shell (cache-first, versioned by build hash); API calls network-only (sync engine handles offline).
- Must pass Lighthouse "installable" check.
- IndexedDB persistence: request `navigator.storage.persist()` on first run.
-`make desktop` → Wails builds per platform (requires per-OS runners; GitHub Actions matrix in `release.yml` producing: `tefter-desktop.exe`, `Tefter.app` zip, linux binary + `.desktop` file).
-`make frontend` → Vite build into `frontend/dist`, embedded by both server and desktop builds.
- Versioning: single version stamp injected via `-ldflags` into both binaries and the frontend; `/health` and the About screen show it.
- Server deployment docs (README): systemd unit example, Caddy reverse-proxy example (2 lines), backup = copy one SQLite file (use `tefterd backup /path` which runs `VACUUM INTO` for a consistent snapshot).
## 10. Milestones
1.**M1 — Core local app:** frontend with omnibar/list/editor per §5, IndexedDB persistence, markdown preview. Runs from `vite dev`. Acceptance: full NV keyboard flow works offline in a browser.
2.**M2 — Server + sync:**`tefterd` with schema, API, token auth; sync engine per §4. Acceptance: two browser profiles converge; conflict copy created on concurrent edit; kill -9 during sync loses nothing.
3.**M3 — Import + PWA:** Simplenote import (CLI + web), service worker, installable on Android. Acceptance: real export imports idempotently; airplane-mode edit on Android syncs on reconnect.
4.**M4 — Desktop:** Wails shell with tray, global shortcut, single instance; release pipeline. Acceptance: fresh Windows/macOS/Linux machine runs the single artifact with zero prerequisites.
5.**M5 — Polish:** diacritic folding, dark mode, tag filter, `[[wiki-links]]`, `tefterd compact`, undo toast.
## 11. Acceptance Criteria (global)
- Search latency <10 ms at 10,000 notes on a mid-range laptop.
- Cold start to focused omnibar <1 s (desktop), <2 s (PWA warm cache).
- Zero data loss under: offline edits, mid-sync crash, concurrent edits on two devices (conflict copy, never silent overwrite).
-`tefterd` runs as one static binary + one `.db` file; no Docker, no external DB, no config file required (flags/env only).
- Entire frontend bundle <500 KB gzipped.
## 12. Explicitly Out of Scope for the Agent
Do not add: user registration, e-mail, websockets/realtime, plugins, themes beyond light/dark, mobile-native builds, Electron, ORMs, Redux-style state libraries, CSS frameworks. When in doubt, choose the smaller solution.