diff --git a/.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md b/.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md
new file mode 100644
index 0000000..107079d
--- /dev/null
+++ b/.planning/phases/04-bbs-polish-and-live-content/04-RESEARCH.md
@@ -0,0 +1,640 @@
+# Phase 4: BBS Polish and Live Content - Research
+
+**Researched:** 2026-02-28
+**Domain:** ANSI art rendering, filesystem watching, directory tree, file metadata, status bar layout
+**Confidence:** HIGH (core stack) / MEDIUM (ansi-to-tui ratatui-core compat)
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+#### ANSI art splash screen
+- Art loaded from a vault file named `splash.txt` in the vault root
+- Colorful ANSI art style using the CGA palette — multi-color block characters
+- If `splash.txt` is missing, show index.md normally — graceful degradation, no fallback banner
+- Splash is rendered above index.md content (header + content below it)
+
+#### Timestamp & page metadata
+- Last-modified timestamp displayed in the status bar (not inline in content)
+- BBS-style format: "Last modified: Feb 25, 2026"
+- Also show file size alongside timestamp, e.g. "Last modified: Feb 25, 2026 | 2.4 KB"
+- Status bar layout: left = breadcrumb, right = metadata + keyboard hints
+
+#### Directory listing
+- Accessed via a special `[[Directory]]` wiki-link (not a keyboard shortcut)
+- Tree view display showing folder hierarchy with indentation
+- Entries show name only — no timestamps or sizes in the listing itself
+- Entries are navigable using existing Tab-cycle link model (Tab between entries, Enter to open)
+- Directory is a virtual page that participates in navigation history
+
+#### Live reload
+- Silent refresh — no visual indicator, content updates seamlessly
+- Scroll position preserved on refresh — user stays where they are
+- Watch scope: current file + vault directory (detect new/removed files for directory listing)
+- Short debounce (~300ms) to avoid flickering from rapid saves
+
+### Claude's Discretion
+- ANSI art parsing implementation (how to interpret color codes from splash.txt)
+- Exact status bar spacing and truncation strategy when terminal is narrow
+- Tree view indentation characters and styling
+- notify crate configuration and event filtering
+- Debounce implementation details
+
+### Deferred Ideas (OUT OF SCOPE)
+None — discussion stayed within phase scope
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| BBS-01 | User sees ANSI art splash screen on index.md | `ansi-to-tui` 8.0.1 crate parses ANSI SGR codes into ratatui `Text`; `Text` lines can be prepended to markdown render output via `Vec` concatenation |
+| BBS-02 | User sees "last updated" file mtime on pages | `std::fs::metadata().modified()` returns `SystemTime`; convert via `duration_since(UNIX_EPOCH)` + pure Gregorian math or lightweight `gregorian` crate; `metadata().len()` gives file size in bytes |
+| LIVE-01 | App watches filesystem for changes and auto-refreshes current page | `notify` 6.1.1 `RecommendedWatcher` with `std::sync::mpsc::channel`; watch parent directory (not the file itself) with `RecursiveMode::NonRecursive`; integrate via `try_recv()` in existing 250ms event loop; manual debounce with `Instant` |
+| LIVE-02 | User can browse a vault-wide directory listing | `walkdir` 2.5.0 for recursive directory walk; `DirEntry.depth()` for tree indentation; `[[Directory]]` magic wiki-link intercepted in `vault::resolve_wiki_link` before filesystem lookup; virtual `DocumentState::Directory` variant |
+
+
+---
+
+## Summary
+
+Phase 4 adds four distinct capabilities that share no common library but all touch the existing `App` event loop and draw pipeline. The ANSI splash screen is the most dependency-sensitive item: `ansi-to-tui` 8.0.1 now depends on `ratatui-core 0.1.0` (extracted from ratatui in the 0.30 modularization) rather than `ratatui` directly. Because this project already uses `ratatui = "0.30.0"` and ratatui 0.30 re-exports all ratatui-core types, the two crates share types and the dependency will resolve correctly — but this must be verified at `cargo add` time.
+
+File metadata (BBS-02) requires no new dependency if a simple hand-rolled Gregorian conversion is acceptable. The calculation — convert unix seconds to year/month/day — is ~30 lines of pure arithmetic. The `gregorian` crate (0.2.4) is an alternative if a dependency is preferred. File size formatting (e.g. "2.4 KB") is straightforward arithmetic with no crate needed.
+
+Live reload (LIVE-01) is the most architecturally significant change. The existing synchronous event loop uses `event::poll(Duration::from_millis(250))` — a blocking call. Integrating `notify` requires a non-blocking `try_recv()` pattern: the notify watcher runs in its own OS thread (spawned internally), sends events through an `mpsc` channel, and the event loop calls `try_recv()` each iteration. Manual debouncing with `Instant::now()` and a 300ms threshold is cleaner than pulling in `notify-debouncer-mini` for this use case. Watching the parent directory rather than the file itself is mandatory because many editors (vim, neovim) save by writing a temp file and renaming, which causes the original inode to be removed — a file-level watch loses the target and stops firing.
+
+**Primary recommendation:** Add `notify = "6.1"`, `ansi-to-tui = "8.0"`, and `walkdir = "2.5"` to Cargo.toml. Use manual debounce with `Instant` rather than `notify-debouncer-mini`. Hand-roll the date formatting to avoid a new time library dependency.
+
+---
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| `notify` | 6.1.1 | Cross-platform filesystem event notifications | Used by rust-analyzer, deno, cargo-watch; the standard choice for sync file watching |
+| `ansi-to-tui` | 8.0.1 | Parse ANSI SGR escape codes → ratatui `Text` | Official ratatui-org crate; only parser targeting ratatui types directly |
+| `walkdir` | 2.5.0 | Recursive directory traversal with depth tracking | BurntSushi crate; used everywhere, returns `DirEntry` with `.depth()` for tree indentation |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `gregorian` | 0.2.4 | Unix timestamp → year/month/day | Only if hand-rolled Gregorian math is undesirable |
+| `notify-debouncer-mini` | 0.7.0 | Debounced filesystem events | Only if manual debounce becomes complex; adds a thread |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `ansi-to-tui` | Hand-roll ANSI SGR parser | ansi-to-tui uses `nom` 8 for robust parsing; hand-rolled would miss edge cases (256-color, reset codes, nested SGR) |
+| Hand-rolled Gregorian math | `gregorian` 0.2.4 or `chrono` 0.4 | Hand-rolled is ~30 lines, no dependency, sufficient for "Jan–Dec" abbreviated month names |
+| Manual debounce | `notify-debouncer-mini` 0.7.0 | Debouncer-mini spawns an extra thread and callback; manual debounce is simpler in a synchronous event loop |
+| `walkdir` | `std::fs::read_dir` recursion | `walkdir` handles symlink loops and provides `.depth()` directly; manual recursion requires a stack |
+
+**Installation:**
+```toml
+# Cargo.toml additions
+notify = "6.1"
+ansi-to-tui = "8.0"
+walkdir = "2.5"
+```
+
+---
+
+## Architecture Patterns
+
+### Recommended Module Structure
+```
+src/
+├── app.rs # Add: watcher field, debounce state, handle_file_change(), virtual directory state
+├── vault.rs # Add: list_vault_files(), resolve_wiki_link intercepts "Directory"
+├── renderer.rs # Add: render_directory_listing() → (Vec, Vec)
+├── splash.rs # NEW: load_splash() → Option>>
+└── main.rs # Add: init watcher after terminal init, pass rx to App::new()
+```
+
+### Pattern 1: Notify Watcher — Parent Directory Watch + try_recv Integration
+
+**What:** Watch the parent directory with `RecursiveMode::NonRecursive`, filter events by filename match in the event handler, use `try_recv()` in the existing event loop (not a separate thread).
+
+**Why parent dir:** Editors like vim write a temp file then rename it over the target. The original inode is deleted; a file-level watch stops firing after the first save. Watching the parent directory with a filename filter catches the rename/create that replaces the file.
+
+**Watch scope decisions:**
+- For a regular document: watch its parent directory, filter events to match `current_path` filename
+- For the directory listing page: watch the vault root (NonRecursive) to detect new/removed `.md` files
+
+**Source:** Official notify docs + GitHub issue #113 (no notifications after saving twice in vim)
+
+```rust
+// Source: https://docs.rs/notify/6.1.1/notify/
+use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
+use std::sync::mpsc::{channel, Receiver};
+use std::time::{Duration, Instant};
+
+pub struct FileWatcher {
+ _watcher: RecommendedWatcher, // kept alive; drop would stop watching
+ pub rx: Receiver>,
+ pub last_event: Instant, // for manual debounce
+ pub watched_dir: PathBuf,
+}
+
+impl FileWatcher {
+ pub fn new(dir: &Path) -> notify::Result {
+ let (tx, rx) = channel();
+ let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
+ watcher.watch(dir, RecursiveMode::NonRecursive)?;
+ Ok(FileWatcher {
+ _watcher: watcher,
+ rx,
+ last_event: Instant::now(),
+ watched_dir: dir.to_path_buf(),
+ })
+ }
+
+ /// Re-point the watcher at a new directory.
+ pub fn rewatch(&mut self, new_dir: &Path) -> notify::Result<()> {
+ self._watcher.unwatch(&self.watched_dir)?;
+ self._watcher.watch(new_dir, RecursiveMode::NonRecursive)?;
+ self.watched_dir = new_dir.to_path_buf();
+ Ok(())
+ }
+}
+
+// In App::run_event_loop — add after event::poll block:
+if let Ok(Ok(event)) = self.file_watcher.rx.try_recv() {
+ // Filter: only act if an event path matches current_path filename
+ let target = Path::new(&self.current_path).file_name();
+ let relevant = event.paths.iter().any(|p| p.file_name() == target);
+ if relevant {
+ self.file_watcher.last_event = Instant::now();
+ }
+}
+// Debounce: fire reload only after 300ms of silence
+if self.file_watcher.last_event.elapsed() > Duration::from_millis(300)
+ && self.pending_reload
+{
+ self.reload_current_document();
+ self.pending_reload = false;
+}
+```
+
+### Pattern 2: ANSI Splash Screen via ansi-to-tui
+
+**What:** Load `splash.txt` as raw bytes, parse with `ansi-to-tui`'s `IntoText` trait, prepend resulting `Vec` to the rendered markdown lines before storing in `DocumentState::Loaded`.
+
+**How multi-line works:** `ansi-to-tui` returns `Text` which has a `.lines: Vec>` field. Prepend these lines to the markdown render output. The combined `Vec` is stored in `DocumentState::Loaded.lines` as usual — no new render path needed.
+
+**Source:** https://docs.rs/ansi-to-tui/8.0.1/ansi_to_tui/
+
+```rust
+// Source: https://docs.rs/ansi-to-tui/8.0.1/ansi_to_tui/
+use ansi_to_tui::IntoText;
+
+pub fn load_splash(vault_path: &Path) -> Option>> {
+ let splash_path = vault_path.join("splash.txt");
+ let bytes = std::fs::read(&splash_path).ok()?;
+ let text = bytes.into_text().ok()?;
+ Some(text.lines)
+}
+
+// In main.rs or wherever index.md is rendered:
+let (mut lines, link_records) = renderer::render_markdown(&content, width, Some(&vault_path));
+if let Some(mut splash_lines) = splash::load_splash(&vault_path) {
+ splash_lines.push(Line::default()); // blank separator
+ splash_lines.extend(lines);
+ lines = splash_lines;
+}
+```
+
+### Pattern 3: File Metadata — Timestamp and Size
+
+**What:** After `vault::load_document()` succeeds, call `std::fs::metadata()` on the full path to get `modified()` and `len()`. Store as optional metadata in `App` state. Format in `draw_status_bar()`.
+
+**Date formatting without external crate:** Convert `SystemTime` to unix seconds via `duration_since(UNIX_EPOCH)`, then compute year/month/day with Gregorian arithmetic.
+
+```rust
+// Source: https://doc.rust-lang.org/std/fs/struct.Metadata.html
+use std::time::UNIX_EPOCH;
+
+pub struct FileMetadata {
+ pub size_bytes: u64,
+ pub modified_display: String, // "Feb 25, 2026"
+ pub size_display: String, // "2.4 KB"
+}
+
+pub fn read_file_metadata(path: &Path) -> Option {
+ let meta = std::fs::metadata(path).ok()?;
+ let size_bytes = meta.len();
+ let modified = meta.modified().ok()?;
+ let secs = modified.duration_since(UNIX_EPOCH).ok()?.as_secs();
+
+ let modified_display = format_unix_date(secs);
+ let size_display = format_file_size(size_bytes);
+
+ Some(FileMetadata { size_bytes, modified_display, size_display })
+}
+
+fn format_file_size(bytes: u64) -> String {
+ if bytes < 1024 {
+ format!("{} B", bytes)
+ } else if bytes < 1024 * 1024 {
+ format!("{:.1} KB", bytes as f64 / 1024.0)
+ } else {
+ format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
+ }
+}
+
+// Pure Gregorian math — no dependency
+fn format_unix_date(unix_secs: u64) -> String {
+ const MONTH_NAMES: [&str; 12] = [
+ "Jan","Feb","Mar","Apr","May","Jun",
+ "Jul","Aug","Sep","Oct","Nov","Dec",
+ ];
+ let (year, month, day) = unix_secs_to_ymd(unix_secs);
+ format!("{} {}, {}", MONTH_NAMES[(month - 1) as usize], day, year)
+}
+
+fn unix_secs_to_ymd(secs: u64) -> (u32, u32, u32) {
+ // Days since epoch
+ let mut days = (secs / 86400) as u32;
+ let mut year = 1970u32;
+ loop {
+ let leap = is_leap(year);
+ let days_in_year = if leap { 366 } else { 365 };
+ if days < days_in_year { break; }
+ days -= days_in_year;
+ year += 1;
+ }
+ let leap = is_leap(year);
+ let month_days: [u32; 12] = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ let mut month = 0u32;
+ for (i, &d) in month_days.iter().enumerate() {
+ if days < d { month = i as u32 + 1; break; }
+ days -= d;
+ }
+ (year, month, days + 1)
+}
+
+fn is_leap(year: u32) -> bool {
+ (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
+}
+```
+
+### Pattern 4: Virtual Directory Page via walkdir
+
+**What:** Intercept `[[Directory]]` in `vault::resolve_wiki_link` before the filesystem scan. Return a sentinel value (e.g., `None` for a file, but navigate via a new `navigate_to_directory()` path). Render a tree view as `Vec>` with `LinkRecord`s pointing to vault-relative paths. Store as `DocumentState::Loaded` with a special filename `"[Directory]"`.
+
+**walkdir API:**
+
+```rust
+// Source: https://docs.rs/walkdir/2.5.0/walkdir/
+use walkdir::WalkDir;
+
+pub fn list_vault_files(vault_path: &Path) -> Vec<(usize, String, bool)> {
+ // Returns (depth, display_name, is_dir) tuples, sorted alphabetically
+ let mut entries: Vec<(usize, String, bool)> = Vec::new();
+ for entry in WalkDir::new(vault_path)
+ .sort_by_file_name() // alphabetical
+ .into_iter()
+ .filter_map(|e| e.ok())
+ .skip(1) // skip vault root itself (depth 0)
+ {
+ let depth = entry.depth();
+ let is_dir = entry.file_type().is_dir();
+ let name = entry.file_name().to_string_lossy().to_string();
+
+ // Skip non-markdown files (except directories)
+ if !is_dir && !name.ends_with(".md") {
+ continue;
+ }
+ // Skip hidden files/dirs
+ if name.starts_with('.') {
+ continue;
+ }
+ entries.push((depth, name, is_dir));
+ }
+ entries
+}
+```
+
+**Tree indentation characters (Claude's discretion — recommended):**
+- `" "` repeated `depth - 1` times (2 spaces per level), no box-drawing connectors
+- Directories: yellow, bold, display name without `.md`
+- Files: cyan (matching existing link style), display name without `.md`
+- Selected entry: existing `Modifier::REVERSED` highlight (same as all links)
+
+**`[[Directory]]` magic link interception in vault.rs:**
+```rust
+// In resolve_wiki_link, before the filesystem scan:
+pub fn resolve_wiki_link(vault_path: &Path, raw_target: &str) -> Option {
+ // Magic sentinel — caller checks for this special string
+ // Return None so App can detect unresolved wiki-link and route to directory
+ // OR use a dedicated sentinel path like PathBuf::from("__directory__")
+ if raw_target.eq_ignore_ascii_case("directory") {
+ return Some(PathBuf::from("__directory__"));
+ }
+ // ... existing filesystem scan ...
+}
+```
+
+Then in `App::follow_selected_link()`, check `resolved == Path::new("__directory__")` and call `navigate_to_directory()` instead of `navigate_to()`.
+
+### Anti-Patterns to Avoid
+
+- **Watching the file itself (not the parent dir):** Vim/neovim write a new file and rename it over the target. The inode being watched is deleted; the watcher fires a `Remove` event and stops receiving `Modify` events for subsequent saves. Always watch the parent directory with a filename filter.
+- **Blocking `rx.recv()` in the event loop:** The existing loop already blocks on `event::poll(250ms)`. A second blocking recv would serialize input handling and file watching instead of interleaving them. Use `try_recv()` which returns immediately.
+- **Caching rendered directory output:** The directory listing must be re-generated on every navigation to it (and on live-reload when watching vault root), otherwise it will show stale entries. Re-render from walkdir each time.
+- **Storing `Text<'a>` from ansi-to-tui with lifetime:** ansi-to-tui returns `Text<'static>` (all strings are owned), but only if the input bytes are `'static`. When reading from a file at runtime, parse immediately and store `Vec>` by calling `.lines` on the `Text` result. Do not store the `Text` struct with a borrow of the file bytes.
+- **Drawing splash as a separate widget:** Prepend splash lines to the content `Vec` before storing in `DocumentState::Loaded`. This keeps the single-Paragraph draw path intact; no Layout changes needed.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| ANSI SGR parsing | Custom escape code tokenizer | `ansi-to-tui` 8.0.1 | SGR handles nested attributes, 256-color, true-color, reset codes — edge cases multiply quickly |
+| Recursive directory walk with depth tracking | Custom `read_dir` recursion stack | `walkdir` 2.5.0 | Symlink loops, permission errors, cross-device paths require robust error handling that walkdir provides |
+| Filesystem event delivery | Manually calling `stat()` each loop iteration | `notify` 6.1.1 | Platform-specific (inotify/kqueue/FSEvents) — `notify` handles all three transparently |
+
+**Key insight:** ANSI parsing looks simple ("just split on `\x1b[`") but breaks on multi-attribute sequences, partial resets, and terminal-specific extensions. The `ansi-to-tui` crate uses `nom` 8 as a proper parser combinator, which handles all of these cases.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Editor Save — Watcher Stops Firing After First Save
+
+**What goes wrong:** You watch `vault/file.md` directly. The user saves in vim once — you see a `Modify` event. They save again — nothing. The watcher has gone silent.
+
+**Why it happens:** vim's default write mode (`nowritebackup` off) renames a temp file over the target. The inode that was being watched is deleted (`Remove` event). The new file at the same path has a different inode; the old watch descriptor is now invalid.
+
+**How to avoid:** Watch the parent directory with `RecursiveMode::NonRecursive`. Filter events by checking `event.paths` for a path whose `file_name()` matches the target file. This survives atomic writes.
+
+**Warning signs:** Works correctly the first save, silently stops working on second save.
+
+**Source:** https://github.com/notify-rs/notify/issues/113 and watchfiles issue #215
+
+### Pitfall 2: Event Loop Blocked — Blocking recv in a Synchronous Loop
+
+**What goes wrong:** Adding `rx.recv()` inside `run_event_loop()` alongside `event::poll(250ms)`. The loop either hangs waiting for a file event or hangs waiting for a keyboard event.
+
+**Why it happens:** Both `event::poll` and `Receiver::recv` block. You cannot block on both simultaneously without threads.
+
+**How to avoid:** Use `rx.try_recv()` which returns immediately with `Err(TryRecvError::Empty)` when no events are queued. The existing 250ms `event::poll` timeout keeps the loop responsive; check `try_recv()` once per iteration.
+
+**Warning signs:** Keyboard input becomes sluggish or freezes when no file events are happening (or vice versa).
+
+### Pitfall 3: ansi-to-tui / ratatui-core Type Mismatch
+
+**What goes wrong:** `cargo build` fails with "expected `ratatui::text::Text`, found `ratatui_core::text::Text`" or similar type mismatch errors.
+
+**Why it happens:** `ansi-to-tui` 8.0.1 depends on `ratatui-core 0.1.0` (a sub-crate extracted in ratatui 0.30). If Cargo resolves two different versions of `ratatui-core` (one via `ratatui = "0.30"` and another directly), types become incompatible even though they look identical.
+
+**How to avoid:** Do not add `ratatui-core` as a direct dependency. Only add `ratatui = "0.30"` and `ansi-to-tui = "8.0"`. Cargo should unify the `ratatui-core` transitive dependency to a single version. If it does not, add a `[patch]` section or explicit `ratatui-core = "0.1"` to force unification.
+
+**Warning signs:** `cargo build` fails immediately after adding `ansi-to-tui` with type mismatch errors involving `ratatui_core` vs `ratatui`.
+
+**Verification step:** Run `cargo tree | grep ratatui-core` after adding the dependency. There should be exactly one version.
+
+### Pitfall 4: Splash Lines Lose 'static Lifetime
+
+**What goes wrong:** Compiler errors about lifetime of `Text<'a>` when `ansi-to-tui` parses borrowed bytes.
+
+**Why it happens:** If you borrow the bytes (e.g., from a `Vec` that you then try to drop), the resulting `Text<'a>` borrows from the source. The existing `DocumentState` stores `Vec>`.
+
+**How to avoid:** Read splash bytes into an owned `Vec` with `std::fs::read()`, then call `.into_text()?` on the owned vec. The owned bytes produce owned spans, yielding `Text<'static>`. Call `.lines` immediately to get `Vec>`.
+
+### Pitfall 5: Directory Listing Stale After Vault File Changes
+
+**What goes wrong:** User creates a new `.md` file in vault. They navigate to `[[Directory]]`. The listing still shows the old files.
+
+**Why it happens:** The directory listing was generated once at navigation time and cached. The live-reload watcher fires for the new file but the directory page has no mechanism to refresh.
+
+**How to avoid:** When the current page is the directory listing (detect via `current_path == "__directory__"`), re-generate the listing whenever a relevant vault root event arrives. Treat the directory listing as ephemeral — always render fresh, never cache.
+
+### Pitfall 6: Status Bar Overflow on Narrow Terminals
+
+**What goes wrong:** "Last modified: Feb 25, 2026 | 2.4 KB Tab:Links Enter:Go Bksp:Back q:Quit" exceeds the terminal width and wraps or corrupts the display.
+
+**Why it happens:** The current `draw_status_bar` builds left and right strings and pads between them. On narrow terminals (e.g., 60 cols), the right side is wider than the available space.
+
+**How to avoid:** Implement graceful truncation: if `left.len() + right.len() >= width`, progressively drop the metadata fields (size first, then date, then hints abbreviation). Reserve 20 chars minimum for breadcrumb. Use `width.saturating_sub()` to prevent panic.
+
+---
+
+## Code Examples
+
+Verified patterns from official sources:
+
+### notify — Non-blocking Integration in Synchronous Event Loop
+```rust
+// Source: https://docs.rs/notify/6.1.1/notify/ + https://doc.rust-lang.org/std/sync/mpsc/struct.Receiver.html
+use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
+use std::sync::mpsc::{self, TryRecvError};
+
+// Setup (once, at app init):
+let (tx, rx) = mpsc::channel();
+let mut watcher = RecommendedWatcher::new(tx, Config::default())?;
+watcher.watch(parent_dir, RecursiveMode::NonRecursive)?;
+
+// In event loop — each iteration, AFTER event::poll():
+match rx.try_recv() {
+ Ok(Ok(event)) => {
+ // Check if event concerns our current file
+ let filename = Path::new(&self.current_path).file_name();
+ if event.paths.iter().any(|p| p.file_name() == filename) {
+ self.pending_reload_at = Some(Instant::now());
+ }
+ }
+ Ok(Err(e)) => { /* watch error — log to debug, ignore */ }
+ Err(TryRecvError::Empty) => { /* no events this tick */ }
+ Err(TryRecvError::Disconnected) => { /* watcher dropped — recreate */ }
+}
+
+// Debounce: fire reload 300ms after last relevant event
+if let Some(t) = self.pending_reload_at {
+ if t.elapsed() >= Duration::from_millis(300) {
+ self.reload_current_document();
+ self.pending_reload_at = None;
+ }
+}
+```
+
+### ansi-to-tui — Parse splash.txt
+```rust
+// Source: https://docs.rs/ansi-to-tui/8.0.1/ansi_to_tui/
+use ansi_to_tui::IntoText;
+use ratatui::text::Line;
+
+pub fn load_splash(vault_path: &Path) -> Option>> {
+ let bytes: Vec = std::fs::read(vault_path.join("splash.txt")).ok()?;
+ let text = bytes.into_text().ok()?;
+ Some(text.lines)
+}
+```
+
+### walkdir — Build Directory Listing
+```rust
+// Source: https://docs.rs/walkdir/2.5.0/walkdir/
+use walkdir::WalkDir;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::text::{Line, Span};
+
+/// Returns (vault_relative_path, display_line) pairs.
+/// Caller converts display_lines into Vec> and builds LinkRecords.
+pub fn build_directory_listing(vault_path: &Path) -> Vec<(Option, Line<'static>)> {
+ let mut out = Vec::new();
+ for entry in WalkDir::new(vault_path)
+ .sort_by_file_name()
+ .into_iter()
+ .filter_map(|e| e.ok())
+ .skip(1) // skip vault root itself
+ {
+ let depth = entry.depth();
+ let name = entry.file_name().to_string_lossy().to_string();
+ if name.starts_with('.') { continue; }
+
+ let is_dir = entry.file_type().is_dir();
+ let indent = " ".repeat(depth - 1);
+
+ if is_dir {
+ // Directory: yellow bold, no link
+ let line = Line::from(vec![
+ Span::raw(indent),
+ Span::styled(name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
+ ]);
+ out.push((None, line));
+ } else if name.ends_with(".md") {
+ // File: cyan link
+ let display = name.strip_suffix(".md").unwrap_or(&name).to_string();
+ // vault_relative_path for LinkRecord.dest
+ let rel = entry.path()
+ .strip_prefix(vault_path)
+ .unwrap()
+ .to_string_lossy()
+ .to_string();
+ let line = Line::from(vec![
+ Span::raw(indent),
+ Span::styled(format!("[{}]", display), Style::default().fg(Color::LightCyan)),
+ ]);
+ out.push((Some(rel), line));
+ }
+ }
+ out
+}
+```
+
+### File Metadata — Timestamp and Size
+```rust
+// Source: https://doc.rust-lang.org/std/fs/struct.Metadata.html
+use std::time::UNIX_EPOCH;
+use std::path::Path;
+
+pub struct PageMeta {
+ pub modified: String, // "Feb 25, 2026"
+ pub size: String, // "2.4 KB"
+}
+
+pub fn read_page_meta(full_path: &Path) -> Option {
+ let meta = std::fs::metadata(full_path).ok()?;
+ let secs = meta.modified().ok()?
+ .duration_since(UNIX_EPOCH).ok()?
+ .as_secs();
+ let (y, m, d) = unix_secs_to_ymd(secs);
+ let months = ["Jan","Feb","Mar","Apr","May","Jun",
+ "Jul","Aug","Sep","Oct","Nov","Dec"];
+ Some(PageMeta {
+ modified: format!("{} {}, {}", months[(m-1) as usize], d, y),
+ size: format_size(meta.len()),
+ })
+}
+
+fn format_size(bytes: u64) -> String {
+ match bytes {
+ 0..=1023 => format!("{} B", bytes),
+ 1024..=1_048_575 => format!("{:.1} KB", bytes as f64 / 1024.0),
+ _ => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
+ }
+}
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| `notify` 4.x/5.x synchronous blocking API | `notify` 6.x callback/channel API; `RecommendedWatcher::new(tx, Config)` | ~2022 | API breaking change; old `raw_watch`, `op::Op` flags gone |
+| `tui` crate (fork) | `ratatui` 0.30 with modular `ratatui-core` sub-crate | 2024–2025 | `ansi-to-tui` 8.x now depends on `ratatui-core` not `ratatui` directly |
+| `ansi-to-tui` 3.x/4.x targeting `tui`/older `ratatui` | `ansi-to-tui` 8.x targeting `ratatui-core 0.1` | 2025 | Must use 8.x with ratatui 0.30 |
+
+**Deprecated/outdated:**
+- `notify` 4.x/5.x: `RawEvent`, `Op` flags, and `Watcher::new()` with `Duration` parameter — replaced by event-based API in 6.x
+- `ansi-to-tui` versions below 6.x: target incompatible ratatui versions
+
+---
+
+## Open Questions
+
+1. **ansi-to-tui 8.0.1 + ratatui 0.30 type unification**
+ - What we know: `ansi-to-tui` 8.0.1 depends on `ratatui-core = "0.1.0"`; `ratatui = "0.30.0"` provides `ratatui-core` as a workspace member/re-export
+ - What's unclear: Cargo's dependency resolver might or might not unify these without explicit version pinning
+ - Recommendation: After `cargo add ansi-to-tui@8`, immediately run `cargo tree | grep ratatui-core` and verify a single version appears. If two appear, pin with `ratatui-core = "0.1"` in `[dependencies]`.
+
+2. **notify version — 6.1.1 vs latest**
+ - What we know: Latest stable seen is 6.1.1; `cargo search` showed `9.0.0-rc.2` as a pre-release
+ - What's unclear: Whether 9.0.0 has breaking API changes vs 6.1.1
+ - Recommendation: Pin to `notify = "6.1"` (stable) until 9.x is stable; avoid the RC.
+
+3. **macOS kqueue vs Linux inotify behavior differences**
+ - What we know: `RecommendedWatcher` selects kqueue on macOS and inotify on Linux
+ - What's unclear: Whether the parent-directory watch pattern for atomic saves behaves identically on both platforms
+ - Recommendation: Test save-with-vim on both platforms during implementation. On macOS, kqueue watches directories natively; the parent-dir pattern should work.
+
+4. **Watcher lifetime — where to store in App**
+ - What we know: `RecommendedWatcher` must be kept alive (dropping stops watching); `_watcher` must live as long as `App`
+ - What's unclear: Whether `RecommendedWatcher` is `Send + Sync` (needed if App is moved across closures)
+ - Recommendation: Store `Option` in `App` (None when watching directory listing). `RecommendedWatcher` is `Send` in 6.x.
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- https://docs.rs/notify/6.1.1/notify/ — watcher API, event types, Config, known problems
+- https://docs.rs/notify/6.1.1/notify/event/struct.Event.html — Event.paths, EventKind
+- https://docs.rs/ansi-to-tui/8.0.1/ansi_to_tui/ — IntoText trait, supported SGR features
+- https://raw.githubusercontent.com/ratatui/ansi-to-tui/main/Cargo.toml — confirmed `ratatui-core = "0.1.0"` dependency
+- https://docs.rs/walkdir/2.5.0/walkdir/ — WalkDir, DirEntry.depth(), sort_by_file_name()
+- https://docs.rs/walkdir/2.5.0/walkdir/struct.DirEntry.html — depth(), file_type(), file_name()
+- https://doc.rust-lang.org/std/fs/struct.Metadata.html — modified(), len(), SystemTime
+- https://doc.rust-lang.org/std/sync/mpsc/struct.Receiver.html — try_recv(), TryRecvError
+- https://docs.rs/ratatui/0.30.0/ratatui/text/struct.Text.html — Text.lines, extend, push_line
+
+### Secondary (MEDIUM confidence)
+- https://oneuptime.com/blog/post/2026-01-25-file-watcher-debouncing-rust/view — manual debounce with Instant pattern (verified against notify docs)
+- https://users.rust-lang.org/t/how-to-get-year-month-day-etc-from-systemtime/84588 — date math options (verified against stdlib docs)
+
+### Tertiary (LOW confidence)
+- GitHub issue notify-rs/notify#113 (no notifications after double save in vim) — via WebSearch summary; pattern confirmed by watchfiles issue #215
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack (notify, walkdir, ansi-to-tui): HIGH — all verified via official docs
+- Architecture patterns: HIGH — derived from verified API signatures
+- ansi-to-tui ratatui-core compat: MEDIUM — Cargo.toml confirmed dependency but runtime type unification not directly tested
+- notify vim/atomic-save pitfall: MEDIUM — confirmed via two independent issues, pattern consistent with inotify documentation
+- Date formatting algorithm: HIGH — pure math, no external dependency
+
+**Research date:** 2026-02-28
+**Valid until:** 2026-03-28 (notify 9.x RC may stabilize; ansi-to-tui compat verified at add time)