> Idea: A markdown-based personal CRM. Every contact is a .md file in a folder. Web UI to browse, search, and add timestamped notes.

Build a local-first, markdown-backed personal CRM called **Rolodex**. The system of record is a flat folder of plain-text `.md` files on disk — one file per contact — and a web UI to browse, search, inspect, edit, and append timestamped notes to those files. The files are the database. A human can open, diff, grep, version-control, and sync them with any tool they already use (Obsidian, iCloud, Dropbox, git), and Rolodex never locks them in. The web app is a thin, fast, beautiful lens over that folder. Every write the UI makes is a deterministic edit to a single `.md` file that a human would be happy to read in a text editor.

## Goal
Create a single-user, self-hosted personal CRM that treats a directory of markdown files as the canonical store. The app must:
- Read a configured folder (`VAULT_DIR`) of `.md` files at startup and watch it for changes.
- Parse each file into a structured contact: YAML frontmatter (typed fields) + a freeform markdown body that contains a reverse-chronological log of timestamped notes.
- Present a contact list (left rail) with instant fuzzy search, filtering by tag/company/status, and sorting.
- Present a contact detail view (main pane) that renders the markdown, surfaces frontmatter as an editable structured panel, and shows the note timeline.
- Let the user **add a timestamped note** to a contact in one keystroke-friendly action; this appends a new entry to that contact's `.md` file with an ISO-8601 timestamp.
- Let the user create a new contact (writes a new `.md` file with a slugified filename and sensible frontmatter skeleton).
- Let the user edit frontmatter fields (name, company, role, email, phone, tags, status, birthday, links, location) through a form that round-trips cleanly back into YAML without clobbering the body.
- Stay correct under external edits: if a file changes on disk (e.g. user edits it in Obsidian), the UI reflects it within ~1s without a manual refresh.
- Never corrupt a file. Writes are atomic, preserve unknown frontmatter keys, preserve body formatting, and are reversible because the underlying store is plain text under the user's own version control.

The product feeling: opening Rolodex should feel like a fast native app — keyboard-driven, zero spinners on local data, dense but calm. Think "Linear meets a text editor for your relationships."

## Tech stack
- **Frontend:** Vite + React 18 + TypeScript. Tailwind CSS for styling. Zustand for client state (contacts cache, selection, search query, UI flags). React Router for two routes: `/` (list + optional empty detail) and `/c/:slug` (selected contact). `cmdk` for the command palette / quick-switcher. `fuse.js` for client-side fuzzy search. `react-markdown` + `remark-gfm` for rendering note bodies. `date-fns` for relative timestamps. `framer-motion` for microinteractions.
- **Backend:** Node + Express + TypeScript, run as a single process that also serves the built SPA. The backend owns the filesystem. Use `gray-matter` to parse/stringify frontmatter, `chokidar` to watch the vault directory, and `slugify` for filenames. No database, no ORM, no SQLite — the folder of `.md` files **is** the data layer. Use an in-memory index (a `Map<slug, Contact>`) rebuilt from disk and kept warm by the watcher.
- **Realtime:** Server-Sent Events (`GET /api/events`) push `contact:changed`, `contact:created`, `contact:deleted`, and `index:reloaded` events so the UI stays in sync with on-disk edits. SSE over WebSockets because the data flow is one-directional server→client and SSE auto-reconnects.
- **Config:** A single env var `VAULT_DIR` (absolute path to the markdown folder); default to `./vault` and auto-create it with three seed example contacts if empty on first run. A second optional `PORT` (default `8765`).
- **Why this stack:** It's boring on purpose. Express + a folder of files is trivially debuggable; gray-matter is the de-facto markdown-frontmatter library; chokidar is the standard watcher; everything an LLM has seen thousands of times. The whole thing runs as `node server.js` and opens one port.

## File / module layout
```
rolodex/
  server/
    index.ts            # Express bootstrap: serves SPA + /api, starts watcher, opens SSE hub
    config.ts           # resolves VAULT_DIR, PORT; ensures vault exists; seeds examples
    vault.ts            # the core: read/parse/write .md files; atomic writes; slug<->path
    index-store.ts      # in-memory Map<slug, Contact>; build(), get(), upsert(), remove()
    watcher.ts          # chokidar wiring -> updates index-store -> emits SSE events
    events.ts           # SSE hub: register clients, broadcast typed events
    routes/
      contacts.ts       # GET list, GET one, POST create, PATCH frontmatter, DELETE
      notes.ts          # POST /api/contacts/:slug/notes  (append timestamped note)
      events.ts         # GET /api/events (SSE stream)
    md/
      parse.ts          # raw file -> Contact (frontmatter + parsed note timeline)
      serialize.ts      # Contact frontmatter + body -> file string (lossless)
      notes.ts          # parse body into Note[]; render Note -> markdown block
    types.ts            # shared Contact, Note, Frontmatter, ApiError types
  src/                  # frontend
    main.tsx
    App.tsx             # router shell, global keybindings, SSE subscription
    store.ts            # Zustand store (contacts, selectedSlug, query, filters, status)
    api.ts              # typed fetch wrappers around /api
    sse.ts              # EventSource client w/ reconnect + dispatch into store
    components/
      Sidebar.tsx           # search box, filter chips, contact list
      ContactListItem.tsx   # avatar initials, name, company, last-contacted badge
      ContactDetail.tsx     # header card + frontmatter panel + note timeline + composer
      FrontmatterPanel.tsx  # structured editable fields, inline edit, save/cancel
      NoteComposer.tsx      # textarea + Cmd/Ctrl+Enter to append note
      NoteTimeline.tsx      # reverse-chron notes, relative time, markdown rendered
      CommandPalette.tsx    # cmdk: jump to contact, new contact, add note, focus search
      NewContactModal.tsx   # name (required) + optional company/email -> POST create
      EmptyState.tsx        # shown when no contact selected / no results / empty vault
      Toast.tsx             # transient success/error notifications
    lib/
      fuzzy.ts          # Fuse config + search helper
      format.ts         # initials, relative time, phone/email normalization
      keys.ts           # global hotkey registry
    styles/
      index.css         # Tailwind layers + CSS variables for the palette
  vault/                # default data dir (gitignored sample contacts live here)
  shared/
    contact-schema.ts   # zod schema for frontmatter validation (imported both sides)
```
Build the frontend with Vite; Express serves the `dist/` output at `/` and mounts the API at `/api`. In dev, Vite proxies `/api` to the Express port.

## Data model
A **contact** is exactly one `.md` file. Its on-disk shape:

```md
---
name: Ada Lovelace
company: Analytical Engines Ltd
role: Chief Mathematician
email: ada@analyticalengines.co
phone: "+44 20 7946 0958"
tags: [vip, math, mentor]
status: active            # one of: active | dormant | prospect | archived
location: London, UK
birthday: 1815-12-10       # ISO date or empty
links:
  - label: Twitter
    url: https://twitter.com/ada
  - label: Site
    url: https://ada.dev
created: 2026-01-04T09:12:00Z
updated: 2026-06-10T17:40:00Z
---

Met at the Difference Engine demo. Warm intro from Charles.

## Notes

### 2026-06-10T17:40:00Z
Followed up re: the loom-punchcard collaboration. She's in. Send the deck by Friday.

### 2026-05-02T11:05:00Z
Coffee at the Royal Society. Talked recursion. She prefers email over calls.
```

**Parsed `Contact` (TypeScript):**
```ts
type Frontmatter = {
  name: string;                 // required, non-empty
  company?: string;
  role?: string;
  email?: string;               // validated as email if present
  phone?: string;               // free string, normalized for display
  tags: string[];               // default []
  status: 'active'|'dormant'|'prospect'|'archived'; // default 'active'
  location?: string;
  birthday?: string;            // ISO date (YYYY-MM-DD) or undefined
  links: { label: string; url: string }[]; // default []
  created: string;              // ISO datetime, set on create
  updated: string;              // ISO datetime, bumped on every write
  [extra: string]: unknown;     // PRESERVE unknown keys verbatim on round-trip
};
type Note = { timestamp: string; body: string };       // body is raw markdown
type Contact = {
  slug: string;                 // filename without .md; stable id
  filePath: string;
  frontmatter: Frontmatter;
  intro: string;                // markdown body ABOVE the "## Notes" heading
  notes: Note[];                // parsed from body, newest first when served
  raw: string;                  // original file contents (for conflict detection)
};
```

**Parsing rules:**
- Frontmatter parsed with gray-matter. Apply the zod schema in `shared/contact-schema.ts`; coerce/default missing typed fields, but **never drop unknown keys** — collect them and re-emit on write.
- The body is split at the first `## Notes` heading. Everything above is `intro`. Everything below is the note log.
- Each note is delimited by a `### <ISO-timestamp>` heading; the text until the next `###` (or EOF) is the note body. Notes are stored newest-first **in the file** (prepend on add) so the most recent note is always near the top of the file when a human opens it.
- `slug` is derived from the filename, not the name, so renaming a contact's display name never renames the file (stable identity). New files are named `slugify(name)` with a `-2`, `-3` suffix on collision.

**Serialization rules (lossless round-trip is the #1 invariant):**
- Re-stringify frontmatter with gray-matter; merge known fields + preserved unknown keys; bump `updated`.
- Reassemble body as `intro` + `\n\n## Notes\n\n` + notes joined newest-first, each as `### <timestamp>\n<body>\n`.
- Trailing-newline-normalize to exactly one `\n` at EOF. Never reflow or reformat the user's intro markdown or note bodies.

## Behavior & features
**Contact list (Sidebar):**
1. Loads the full index on mount via `GET /api/contacts` (returns lightweight rows: slug, name, company, status, tags, lastNoteAt, initials).
2. Search box at top is always focused on `/`. Fuzzy search (Fuse) across name, company, role, email, tags. Results update on each keystroke with highlighted match ranges. Empty query shows all contacts.
3. Filter chips below search: status (active/dormant/prospect/archived) and a tag dropdown. Chips are toggle-able and combine with search (AND).
4. Sort control: Recently contacted (default, by `lastNoteAt` desc), A→Z, Recently added (`created` desc).
5. Each row shows: a colored initials avatar (deterministic color from slug hash), name, company subtitle, and a right-aligned relative "last contacted" badge (e.g. "3d", "2mo"); status reflected by a 2px left accent bar color.
6. Clicking a row (or ↑/↓ + Enter) navigates to `/c/:slug` and loads the full contact.

**Contact detail (ContactDetail):**
1. Header card: large initials avatar, name, role @ company, status pill, and quick-action buttons (Add note, Edit details, Copy email, Open links).
2. FrontmatterPanel: structured, inline-editable fields. Click any field to edit; Enter/blur saves via `PATCH /api/contacts/:slug` (frontmatter-only patch); Esc cancels. Tags edited as a chip input. Links edited as add/remove rows. Email/phone get copy buttons.
3. Intro markdown rendered read-only with an "edit intro" toggle that swaps to a textarea.
4. NoteComposer: a textarea pinned above the timeline. `Cmd/Ctrl+Enter` (or the Add button) calls `POST /api/contacts/:slug/notes { body }`, which appends a `### <now ISO>` note to the file and returns the updated contact. The new note animates in at the top of the timeline; the composer clears and refocuses.
5. NoteTimeline: reverse-chronological notes. Each note shows an absolute date (hover for exact timestamp) + relative time, with the body rendered as markdown (GFM: lists, links, code, checkboxes). Notes are read-only in v1 (edit-in-file is the escape hatch), but each has a "copy" affordance.

**Command palette (cmdk):** `Cmd/Ctrl+K` opens it. Actions: jump to any contact (fuzzy), "New contact", "Add note to {current}", "Focus search", "Toggle sort". Typing filters across both contacts and actions.

**Create contact:** `Cmd/Ctrl+N` or palette opens NewContactModal. Name required; optional company/email. On submit, `POST /api/contacts` writes a new `.md` with the frontmatter skeleton (status `active`, empty notes, `created`/`updated` = now) and navigates to it.

**Realtime sync:** App opens one `EventSource` to `/api/events`. On `contact:changed`/`created`/`deleted` it patches the Zustand cache and, if the changed contact is currently open, reloads its detail (with a subtle "updated on disk" toast if the user has unsaved composer text). On `index:reloaded` it refetches the list. This is what makes external edits in Obsidian show up live.

**Keyboard model:** `/` focus search · `↑/↓` move selection · `Enter` open · `Cmd/Ctrl+K` palette · `Cmd/Ctrl+N` new · `Cmd/Ctrl+Enter` send note · `Esc` cancel/close. Show a `?` shortcuts overlay.

## UX & visual design
**Aesthetic:** modern, dark, polished, dense-but-calm — a tool you live in.
- **Palette (hex):** background `#0B0D10`, surface `#14171C`, surface-raised `#1B1F26`, border `#262B33`, text-primary `#E6E9EF`, text-secondary `#9AA3B2`, text-muted `#5C6675`, accent `#6E8BFF` (indigo), accent-hover `#8AA0FF`, success `#3FB984`, warning `#E0A33E`, danger `#E5616B`. Status colors: active `#3FB984`, dormant `#9AA3B2`, prospect `#6E8BFF`, archived `#5C6675`.
- **Typography:** UI font Inter (system fallback `-apple-system, Segoe UI, sans-serif`); monospace `ui-monospace, "JetBrains Mono", monospace` for timestamps and code. Scale: 12/13/14/16/20/28px. Line-height 1.5 body, 1.2 headings. Note bodies render at 14px with comfortable 1.6 line-height.
- **Spacing scale:** 4 / 8 / 12 / 16 / 24 / 32 / 48px. Sidebar width 320px; detail max-width 760px centered with generous gutters. List rows 56px tall with 12px internal padding.
- **Radii & depth:** 8px cards, 6px inputs, 999px pills/avatars. One soft shadow on raised surfaces (`0 1px 0 rgba(255,255,255,0.03) inset, 0 8px 24px rgba(0,0,0,0.4)`). 1px borders using `border`.
- **Motion:** framer-motion. List selection has a 120ms ease background transition. New notes slide+fade in (`y: -8 → 0`, opacity `0 → 1`, 180ms). Palette opens with a 150ms scale-from-0.98 + backdrop blur. Toasts slide up from bottom-right, auto-dismiss 3.5s. All motion respects `prefers-reduced-motion` (reduce to opacity-only).
- **Avatars:** initials on a deterministic HSL color derived from the slug hash, dark-friendly (50% lightness, 45% saturation) with `#0B0D10` text.
- **Empty/loading:** local data renders instantly (no skeletons needed on cached list). Detail fetch shows a 1-frame fade only if >150ms. EmptyState components for: empty vault ("Add your first contact"), no search results ("No matches for '{q}'"), and no selection ("Pick someone on the left").
- **Density & polish:** subtle hover states on every interactive element, focus rings using `accent` at 40% for keyboard users, copy buttons that flash a check on success.

## Edge cases & error handling
- **Malformed frontmatter / invalid YAML:** don't crash the index build. Mark the contact `parseError`, still show it in the list with a warning glyph, and render the raw file read-only in detail with an error banner. Refuse structured writes to a broken file until the user fixes it on disk.
- **Unknown / extra frontmatter keys:** must survive a full read→edit→write cycle untouched. Add a round-trip test that asserts byte-stable output for keys the UI doesn't know.
- **External edit while editing:** detect via `raw` mismatch on write (compare server's current file bytes to the `raw` the client loaded). On conflict, return `409` with the latest contact; UI shows "This contact changed on disk" and offers Reload (discard local) — never silently overwrite.
- **Concurrent note appends / fast double-submit:** serialize writes per-file with an in-process async mutex (Map<filePath, Promise>); debounce the composer's submit; dedupe identical note bodies within 2s.
- **Filename vs name drift / slug collisions:** new files slugify the name; on collision append `-N`. Renaming display name never touches the filename. Empty/duplicate/over-long names: trim, fall back to `contact-<timestamp>`, cap slug at 80 chars.
- **Non-contact `.md` files & subfolders:** index only `*.md` in the (optionally recursive) vault; skip `README.md` and dotfiles; ignore non-`.md`. Files with no frontmatter still load (treat whole file as intro, synthesize a name from the filename).
- **Missing `## Notes` section:** treat the entire body as intro; create the `## Notes` section on first note append.
- **Watcher storms / save-loops:** debounce chokidar events 200ms; ignore the app's own writes for a 500ms window (track a recent-writes set by path+mtime) so a UI write doesn't bounce back as an external-change toast.
- **Filesystem errors:** ENOENT (deleted under us) → emit `contact:deleted`, drop from index, route home if open. EACCES/EPERM → surface a clear toast and a 500 with a human message. Disk full / write failure → atomic write (write to `.tmp` then rename) so a partial write never lands; on failure, original file is intact.
- **Unicode & encoding:** read/write UTF-8 only; preserve emoji and non-Latin names; normalize newlines to `\n` on write but don't touch CRLF inside code fences in note bodies beyond that.
- **Timezones:** store timestamps in UTC ISO-8601 (`Z`); render in the browser's local zone with a tooltip showing the absolute UTC value.
- **XSS in markdown:** sanitize rendered markdown (no raw HTML, or sanitize via rehype-sanitize) since note content is user-authored.
- **Empty vault on boot:** seed three example contacts (incl. Ada above) so the app is never a blank void; clearly real-looking samples the user can delete.
- **API errors:** every endpoint returns `{ error: { code, message } }` with correct status (400 validation, 404 missing, 409 conflict, 500 fs). The frontend surfaces these as toasts, never silent failures.

## Definition of done
- [ ] `node server/index.ts` (built) boots, resolves `VAULT_DIR` (auto-creating + seeding if empty), builds the in-memory index from disk, and serves the SPA + API on one port.
- [ ] Sidebar lists all contacts with avatars, company, status accent, and "last contacted" badges; fuzzy search and status/tag filters and sort all work and combine.
- [ ] Selecting a contact shows the detail view: header card, editable frontmatter panel, rendered intro, and a reverse-chron note timeline.
- [ ] Adding a note via composer (`Cmd/Ctrl+Enter`) appends a correctly-timestamped `### <ISO>` block to the contact's `.md` file, prepended newest-first, and the new note animates into the timeline.
- [ ] Editing a frontmatter field PATCHes and rewrites the file losslessly: known fields update, unknown keys are preserved byte-for-byte, `updated` is bumped, and a round-trip test proves byte-stability.
- [ ] Creating a contact writes a new slugified `.md` with the frontmatter skeleton and navigates to it; collisions get `-N` suffixes.
- [ ] Editing a file externally (in a text editor / Obsidian) reflects in the open UI within ~1s via SSE, without manual refresh, and without losing unsaved composer text (shows an "updated on disk" toast instead).
- [ ] Atomic writes (tmp + rename) and a per-file write mutex guarantee no file is ever left half-written or corrupted under concurrent or rapid edits.
- [ ] Conflicting writes (file changed on disk since load) return 409 and prompt the user to reload rather than overwriting.
- [ ] Malformed YAML, missing frontmatter, missing `## Notes`, non-`.md` files, dotfiles, and unicode names are all handled without crashing the index.
- [ ] Command palette (`Cmd/Ctrl+K`), new contact (`Cmd/Ctrl+N`), search focus (`/`), arrow navigation, and the `?` shortcut overlay all work.
- [ ] Rendered markdown is sanitized (no XSS); timestamps stored UTC and shown in local time with absolute tooltips.
- [ ] The dark visual system matches the specified palette, typography, spacing, radii, and motion, and respects `prefers-reduced-motion`.
- [ ] Empty states exist for empty vault, no search results, and no selection; errors always surface as toasts with meaningful messages.
- [ ] At no point does the app require a database — the folder of `.md` files is fully sufficient, human-readable, and git-friendly, and deleting the app leaves a clean, usable markdown vault behind.
