- frontend/: Svelte 4 + TS + Vite app — omnibar (search-and-create), ranked diacritic-insensitive filtering, CodeMirror 6 markdown editor with 400ms autosave, marked+DOMPurify preview with [[wiki-links]], tag filter, undo toast, light/dark, narrow-screen stacked layout, IndexedDB store, pull-then- push sync engine with conflict handling, versioned cache-first service worker + manifest (installable PWA) - server/: tefterd — Go stdlib HTTP + modernc.org/sqlite, /api/v1 sync API (changes/batch/health/import), SHA-256 hashed bearer token, LWW-with- conflict-copies push rules, subcommands: init, token rotate, import simplenote, compact, backup (VACUUM INTO); embeds the frontend bundle - desktop/: Wails v2 shell — single instance, hide-to-tray (fyne systray), global Ctrl+Shift+Space hotkey, quit-on-close flag - Simplenote import (CLI + web upload), idempotent via source_id dedupe; verified against a real 242-note export - Makefile (frontend/server/server-all/desktop/test), GitHub release workflow, README with systemd/Caddy/backup docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
17 KiB
Tefter — Self-Hosted Notational Velocity Clone
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.
Non-Goals
- Multi-user accounts, sharing, collaboration, realtime co-editing.
- End-to-end encryption (TLS in transit + disk encryption at rest is the model).
- Rich text, attachments, images (v1 is plain Markdown text; attachments may come in v2).
- Native Android app (PWA is the Android client).
2. Architecture
┌─────────────────────────────── your data center ───────────────┐
│ tefterd (single Go binary) │
│ ├─ REST sync API /api/v1/... │
│ ├─ static web app (PWA) embedded via go:embed → / │
│ └─ SQLite database (modernc.org/sqlite, CGO-free) │
└─────────────────────────────────────────────────────────────────┘
▲ HTTPS (reverse proxy: Caddy/nginx, user-provided)
│
┌────────┼─────────────┬───────────────────┐
│ │ │ │
Web Android Desktop (Win/mac/Linux)
browser (installed tefter-desktop: Wails v2 binary
(PWA) PWA) wrapping the SAME frontend bundle
Key decision — one frontend, three shells. The web app is the product. It runs:
- In a browser (PWA, served by
tefterd). - Installed on Android (PWA install prompt).
- Inside a Wails v2 webview window on desktop (frontend bundle embedded in the desktop binary; works fully offline, no
tefterdneeded 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). - Frontend: Svelte 4 + TypeScript + Vite. Keep dependencies minimal. Markdown rendering:
marked+DOMPurify. Editor: CodeMirror 6 (markdown language package, minimal setup). - Desktop: Wails v2.
- No ORM, no heavy frameworks, no CSS framework — hand-written CSS, this UI is small.
3. Data Model
Server SQLite schema
CREATE TABLE notes (
id TEXT PRIMARY KEY, -- UUIDv4, client-generated
content TEXT NOT NULL DEFAULT '', -- full markdown; title = first line
tags TEXT NOT NULL DEFAULT '[]',-- JSON array of strings
created_at INTEGER NOT NULL, -- unix ms, client clock at creation
modified_at INTEGER NOT NULL, -- unix ms, client clock at last edit
deleted INTEGER NOT NULL DEFAULT 0,-- tombstone
version INTEGER NOT NULL -- server-assigned, globally monotonic
);
CREATE INDEX idx_notes_version ON notes(version);
CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT); -- schema_version, next_version counter
- Title is derived, never stored: first non-empty line of
content, markdown heading markers stripped for display. versionis 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 compactsubcommand may purge tombstones older than N days.
Client store (IndexedDB)
Object store notes: same fields as server, plus:
dirty: boolean— locally modified, not yet pushed.baseVersion: number— server version this local copy was derived from (0 for never-synced).
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 | /health |
Liveness + schema version |
| POST | /import/simplenote |
Multipart upload of Simplenote export zip (also available as CLI) |
Push conflict rule (server-side, per note)
- If note id unknown → insert, assign new version. Accepted.
- If
baseVersion == current version→ clean fast-forward. Update, assign new version. Accepted. - 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
conflictadded; its first line gets(conflicted copy YYYY-MM-DD HH:MM)appended. - Response marks the note
{id, status: "conflict", conflictCopyId: ...}.
- 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
- On startup, on network regain, after 3 s of edit inactivity, and every 60 s: run sync if online.
- Pull: page through
/changesfrom localcursor. 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. Updatecursor. - Push: send all dirty notes in one batch. On
accepted, clear dirty, setbaseVersionto returned version. Onconflict, replace local with server truth and add the returned conflict copy on next pull. - 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 initprints it), stored hashed (SHA-256) inmeta. Rotate withtefterd token rotate. - No accounts, no sessions, no OAuth. TLS is delegated to the user's reverse proxy;
tefterdlistens 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
├──────────────────────────────────────────────┤
│ Note title · modified · tags │ ← results list
│ Note title · modified · tags │ (selected row highlighted)
│ ... │
├──────────────────────────────────────────────┤
│ │
│ 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)
- Focus is in the omnibar when the app opens.
- 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).
- Ranking: exact title match > title prefix > title contains > body contains. Secondary sort:
modified_atdesc. Empty query shows all notes bymodified_atdesc. - Matching is case-insensitive, diacritic-insensitive (č/c, š/s, ž/z fold together), across space-separated terms (AND semantics).
- 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.
- ↓/↑ from the omnibar moves the list selection without leaving the field; the editor live-previews the selected note (NV behavior).
- Esc: clear omnibar, deselect, focus omnibar. Pressing Esc in the editor returns focus to the omnibar (query preserved).
- Search term highlighting in list rows and in the editor.
Keyboard shortcuts (Cmd on macOS, Ctrl elsewhere)
| Shortcut | Action |
|---|---|
| Cmd/Ctrl+L | Focus omnibar (select existing text) |
| Enter (omnibar) | Open selection / create note |
| ↑ ↓ (omnibar) | Move list selection |
| Esc | Editor→omnibar; omnibar→clear |
| Cmd/Ctrl+Delete | Delete selected note (moves to tombstone; brief undo toast, no confirm dialog) |
| Cmd/Ctrl+Shift+P | Toggle markdown preview for current note |
| Cmd/Ctrl+K | Cycle tag filter (simple tag dropdown) |
| Cmd/Ctrl+J / Cmd/Ctrl+Shift+J | Next / previous note in list while in editor |
Editor
- 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+Pswaps 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). - Web: Settings → Import → upload zip (uses
POST /api/v1/import/simplenote). - Mapping: content → content; creationDate/lastModified (ISO 8601) → unix ms; tags → tags; trashedNotes → imported with
deleted=1tombstones (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_idcolumn, 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/distembedded 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. - Android keyboard ergonomics: omnibar
enterkeyhint="go", no zoom-on-focus (font-size ≥16 px).
9. Build, Release, Deployment
make server→tefterdbinaries for linux/amd64, linux/arm64, darwin/arm64, windows/amd64 (pure Go,CGO_ENABLED=0).make desktop→ Wails builds per platform (requires per-OS runners; GitHub Actions matrix inrelease.ymlproducing:tefter-desktop.exe,Tefter.appzip, linux binary +.desktopfile).make frontend→ Vite build intofrontend/dist, embedded by both server and desktop builds.- Versioning: single version stamp injected via
-ldflagsinto both binaries and the frontend;/healthand 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 /pathwhich runsVACUUM INTOfor a consistent snapshot).
10. Milestones
- 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. - M2 — Server + sync:
tefterdwith 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. - 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.
- 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.
- 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).
tefterdruns as one static binary + one.dbfile; 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.