# AGENTS.md

## System Intent & Context

This repository is a high-performance, rule-based **block markdown editor** built with Vue 3, TypeScript, and the Lezer parsing framework. Documents are structured as distinct, interactive **Blocks** rather than a single flat string. A 2-stage parsing pipeline bridges UI state down to ASTs, enabling real-time extraction of pages, references, task state, callouts, and inline decorations.

---

## Monorepo Structure

```
apps/dev/          Development and testing sandbox (Vue 3 + Vite + TypeScript)
packages/editor/   Core headless engine — parsers, rule registries, composables, tests
```

**Component flow (editor shell):**

```
apps/dev/src/pages/editor-shell.vue
  └── apps/dev/src/components/Editor.vue
        └── packages/editor/src/components/Editor.vue    ← multi-block shell
              ├── packages/editor/src/components/EditorInsertionZone.vue
              └── packages/editor/src/components/EditorBlock.vue    ← self-contained block
                    ├── packages/editor/src/components/EditorSuggestionMenu.vue
                    ├── packages/editor/src/composables/useMarkdownDecorations.ts
                    │     └── packages/editor/src/lib/markdown-parser.ts
                    │           └── packages/editor/src/lib/markdown-rules/engine.ts
                    │                 ├── block-classifier.ts  (paste only)
                    │                 ├── inline-rules.ts
                    │                 └── block-rules.ts
                    ├── packages/editor/src/composables/useBlockKeyboardHandlers.ts
                    └── packages/editor/src/composables/usePatternPlugin.ts
```

---

## Parsing Pipeline

All syntax features flow through a 2-stage pipeline in `packages/editor/src/lib/markdown-rules/engine.ts`:

```
[Raw Block Content String]
       │
       ├── Phase 1: Lezer AST Visitor
       │     ├─ enter → block rules (headings, blockquotes, callouts, tasks)
       │     └─ leave → inline rules (bold, italic, code, links, refs, tags, highlights)
       │
       ├── Phase 2: Property Extraction    →  Suffix Matcher (Key::Value)
       │
       ▼
[ParsedDecorations — unified output struct]
```

> **Note**: `classifyBlock()` in `block-classifier.ts` is used only by the paste handler (`usePasteHandler.ts`), not by the main decoration engine.

### Unified Output Contract

```typescript
export interface DecorationRange {
  type: "hidden" | "content"
  from: number
  to: number
  className?: string
}

export interface ParsedDecorations {
  content: (TokenDecoration | PageRefDecoration | BlockRefDecoration | TagDecoration | HighlightDecoration)[]
  properties: PropertyInfo[]
  blocks: BlockDecoration[]
}
```

Do not change this interface without updating both unit tests and block render cycles.

---

## Supported Syntax

### Task States

```
TODO Fix the critical focus bug
└──┘
status
```

- **Valid states:** `TODO`, `DOING`, `DONE`, `LATER`, `NOW`, `WAITING`, `CANCELLED`
- **State progression:** `TODO → DOING → DONE` (linear). `LATER`, `NOW`, `WAITING`, `CANCELLED` are orthogonal.

### Obsidian-Style Elements

| Syntax | Example |
|---|---|---|
| Page reference | `[[Page Name]]` or `[[Real Page\|Display Name]]` |
| Block reference | `((block-id-1234))` |
| Callout | `> [!NOTE] Description text` |
| Property | `author:: John Doe` |
| Table | `\| A \| B \|\n\|---\|---\|\n\| 1 \| 2 \|` |

### Tables

Tables use a **two-state rendering pattern**: blurred → source hidden, HTML widget rendered via `renderTableHtml()` → `renderCellContent()` (runs each cell through the Lezer parser for inline markdown → HTML conversion); focused → `|` pipes styled as `md-decorator`, cell content editable as raw markdown. Clicking the rendered table focuses the block.

**Valid callout types:** `NOTE`, `WARNING`, `TIP`, `DANGER`, `INFO`

---

## Technology Stack

| Layer | Technology | Rule |
|---|---|---|
| Framework | Vue 3 (Composition API, `<script setup>`) | Use composables for local state slices (e.g. `useBlockKeyboardHandlers`). |
| Type Safety | TypeScript (`strict: true`) | No implicit `any`. Use strict sum-types for internal configs like `TaskState`. |
| AST Parsing | `@lezer/common` / Lezer | Prefer specific `SyntaxNode` targeting over brute-force regex on blocks. |
| Formatting | Biome | Run `biome check` before committing. |
| Testing | Vitest | Run `vitest run` to verify. Every new rule must have a mirror test. |

---

## How to Add a New Rule

There are **three registration points** in the pipeline. Every new syntax touches all three.

| # | File | Purpose |
|---|---|---|
| 1 | `markdown-extensions.ts` | Define the Lezer node type via `MarkdownConfig` (so the parser produces AST nodes for your syntax) |
| 2 | `inline-rules.ts` / `block-rules.ts` | Create a `MarkdownRule<unknown>` or `BlockRule` factory that matches the new node type and returns decorations |
| 3 | `engine.ts` (or consumer code) | Register the rule via the `extraInlineRules`/`extraBlockRules` constructor param, or add to `createInlineRules()`/`createBlockRules()` for built-in syntax |

Follow the example below — it adds a `::foo::` inline syntax end to end.

---

### Step 1 — Define the Lezer node (`markdown-extensions.ts`)

If your syntax isn't already a standard GFM node, add a `MarkdownPattern` entry to `defaultPatterns` or `createMarkdownExtensions()`:

```typescript
// markdown-extensions.ts — add to defaultPatterns array
{
  name: "FooMarker",
  start: "::",
  end: "::",
  nodeType: "FooMarker",
  decoration: {
    contentClass: "md-foo",
    hiddenClass: "md-hidden",
  },
}
```

This creates a `"FooMarker"` Lezer inline node for text between `::` delimiters. For block-level syntax (e.g. `::component{attrs}`), use a `LeafBlockParser` instead — see the existing `TaskParser` or [MdcParser in the MDC plan](.agents/plans/mdc.md).

**Reference:** [@lezer/markdown MarkdownConfig API](https://lezer.codemirror.net/docs/ref/#markdown.MarkdownConfig)

---

### Step 2 — Create a decoration rule (`inline-rules.ts` or `block-rules.ts`)

Build a factory function that matches your Lezer node type and returns `DecorationRange` objects:

```typescript
// inline-rules.ts
function createFooRule(): MarkdownRule<unknown> {
  return {
    name: "foo",
    nodeTypes: ["FooMarker"],
    match(node) {
      return node.type.name === "FooMarker"
    },
    run(node, ctx) {
      // Return two ranges: hidden delimiters + visible content
      return [
        { type: "hidden", from: node.from, to: node.from + 2, className: "md-hidden" },
        { type: "content", from: node.from + 2, to: node.to - 2, className: "md-foo" },
        { type: "hidden", from: node.to - 2, to: node.to, className: "md-hidden" },
      ]
    },
  }
}
```

For block-level syntax, create a `BlockRule` instead (see `createHeadingRule`, `createTableRule` in `block-rules.ts`).

---

### Step 3 — Register the rule

**For external/plugin code** (recommended — no source changes needed):

```typescript
import { MarkdownRuleEngine } from "@enesis/editor"

const engine = new MarkdownRuleEngine({
  extraInlineRules: [createFooRule()],
  // extraBlockRules: [createMyBlockRule()],
})
```

**For built-in syntax** (modify `engine.ts`):

```typescript
// engine.ts constructor
this.inlineRules = [
  ...createInlineRules(),
  createFooRule(), // ← add here
]
```

---

### Step 4 — Write a test

Add a test in `packages/editor/src/lib/__tests__/markdown-parser.test.ts`:

```typescript
describe("FooRule", () => {
  it("decorates foo content correctly", () => {
    const result = engine.parse("hello ::world:: foo")
    expect(result.content).toContainEqual({
      type: "content",
      from: 8,
      to: 13,
      className: "md-foo",
    })
  })
})
```

---

### Step 5 — Cursor behavior

For any rule with `hidden` ranges: confirm that `from`/`to` offsets are computed relative to the block's document offset, not the raw fragment string. Check `createDelimitedRule` as the reference.

---

### Step 6 — Run checks

```bash
biome check packages/editor/src/lib/markdown-rules/
vitest run
```

Both must pass before the change is complete.

---

## Cursor Behavior — Hidden vs. Content Ranges

Decorations alternate between `hidden` (delimiters) and `content` (payload). For example, in `**bold**`:

- Positions 0–1 (`**`) → `hidden` when cursor is inside content (positions 2–5)
- Positions 2–5 (`bold`) → `content`
- Positions 6–7 (`**`) → `hidden` when cursor is inside content

**Rules:**
- `hidden` ranges disappear from view when the cursor is inside the enclosing `content` range.
- `content` ranges are always visible.
- Never update extraction slices without verifying that cursor offset tracking remains accurate for both the `hidden` and `content` segments.

---

## Debug Logging

A namespace-based logger system in `packages/editor/src/lib/logger.ts`.

### Enabling logs

| Method | Example |
|---|---|
| **Global** (all blocks) | `localStorage.setItem("editor:debug", "*")` |
| **By namespace** | `localStorage.setItem("editor:debug", "CodeBlockView,Block.Enter")` |
| **Per component instance** | `<EditorBlock debug="CodeBlockView,Block.Enter" />` |

After setting, refresh and open DevTools console. Namespaces set via the `debug` prop are scoped to that component instance and its descendants. The `localStorage` setting applies globally.

### Available namespaces

| Namespace | Source File | What it logs | Level |
|---|---|---|---|
| `Block` | `EditorBlock.vue` | Content changes, paste classification, external syncs | `debug` |
| `CodeBlockView` | `useCodeBlockView.ts` | Constructor, language detection, CM6↔PM sync, fence deletion conversion | `info`/`debug` |
| `Block.Enter` | `useBlockKeyboardHandlers.ts` | Enter keystroke detection, fence match results | `debug` |
| `ContentModel` | `content-model.ts` | `contentToDoc`/`docToContent` input/output sizes, node type breakdown | `debug` |
| `AutoClose` | `auto-close-plugin.ts` | Triple-backtick, bold, and italic auto-close rule triggers | `info` |
| `Decorations` | `useMarkdownDecorations.ts` | Cache hit/miss ratio, decoration count per build | `debug` |
| `PatternPlugin` | `usePatternPlugin.ts` | Pattern detection session open/update/close transitions | `info` |
| `ParseEngine` | `engine.ts` | Parse duration, rule match counts | `debug` |

### Adding logs to new code

```typescript
import { createLogger } from "@/lib/logger"
const log = createLogger("MyFeature")
log.debug("only visible with filter enabled")
log.info("visible by default")
log.warn("always visible")
log.error("always visible")
```

For component-aware filtering (respects the `debug` prop), pass the filter string from the component:

```typescript
// In a composable factory:
export function createMyThing(componentFilter?: string) {
  const log = createLogger("MyFeature", componentFilter)
  // ...
}

// In EditorBlock.vue:
createMyThing(props.debug)
```

### Log levels

- `info` — notable events (auto-close triggered, code block created, pattern opened)
- `debug` — high-frequency events (every keystroke sync, decoration rebuild, paste classification)
- `warn` / `error` — unexpected states, always visible

If console output is too noisy, filter to `info`+: `localStorage.setItem("editor:debug", "*")` and set min level via `configureDebug({ minLevel: "info" })` in DevTools.

---

## Auto-Close Plugin

Defined in `packages/editor/src/lib/auto-close-plugin.ts`. A ProseMirror `Plugin` that intercepts text input (`handleTextInput`) and backspace (`handleDOMEvents.keydown`) to auto-close bracket pairs, toggle delimiters, and create code blocks.

### Factories

| Factory | Purpose | Returns |
|---|---|---|
| `createPairRule(open, close, opts?)` | Brackets `()`, `[]`, `{}`, `""`, `''` | `AutoCloseRule` |
| `createToggleRule(delimiter, opts?)` | Multi-char toggles `**`, `~~` | `AutoCloseRule` |

### `createPairRule` behavior

- **Auto-close**: typing `(` inserts `()` with cursor between
- **Skip-over**: typing `)` when next char is `)` moves cursor past it (only when no text selected)
- **Wrap selection**: typing `(` with selected text wraps it as `(selected)` with cursor after `)`
- **Delete-pair**: Backspace between `()` deletes both characters
- **Context option**: regex filters when auto-close fires (e.g. `"` only after whitespace/structural chars)

### `createToggleRule` behavior

- Requires multi-character delimiter (`**`, `~~`, not `*` or `~`)
- Typing the last character of the delimiter when prefix already present inserts delimiter pair, cursor between
- **Word boundary**: optional `wordBoundary: true` prevents mid-word triggering

### Structural rules (built-in)

| Rule | Trigger | Action |
|---|---|---|
| `tripleBacktickRule` | ``` at paragraph start on third `` ` `` | Replaces paragraph with `code_block` node, cursor after opening fence |
| `doubleAsteriskRule` | `**` (via `createToggleRule`) | Inserts `****`, cursor between |
| `doubleTildeRule` | `~~` (via `createToggleRule`) | Inserts `~~~~`, cursor between |
| `singleUnderscoreRule` | `_` at word boundary | Inserts `__`, cursor between; no auto-close mid-word |

### Registration

In `EditorBlock.vue`, pass rules to `autoClosePlugin([...])` in the EditorState's `plugins` array. Rules are evaluated in order; the first matching rule handles the event.

---

## Suggestion Menu

Each `EditorBlock.vue` renders its own `EditorSuggestionMenu` instance inline (not teleported). The menu is positioned with `position: absolute` inside a `position: relative` wrapper so it scrolls naturally with content.

### Trigger flow

```
PM input → pattern plugin detects trigger (/ [[ (( #)
  → onPatternOpen callback
    → localSession ref set in EditorBlock
    → EditorSuggestionMenu becomes visible
    → capture-phase keydown listener registered on document
```

### Keyboard handling

Keyboard navigation is **not** handled by `useKeyboard` in the Editor shell. Each `EditorBlock.vue` registers its own capture-phase listener when its `localSession` is active:

| Key | Action |
|---|---|
| `ArrowDown` | Highlight next item (wraps) |
| `ArrowUp` | Highlight previous item (wraps) |
| `Enter` | Select highlighted item |
| `Escape` | Close menu, cancel session |

The Editor's `useKeyboard` only handles `Escape` (close-palette), `Mod+Z` (undo), and `Mod+Shift+Z` (redo). Arrow navigation keys pass through the Editor's handler (the keybinding returns `null` for them) and are caught by the focused Block's capture listener.

### Position recalculation on scroll

A passive `window` scroll listener calls `view.coordsAtPos(session.range.from)` on scroll and converts viewport coords to wrapper-relative coords by subtracting `getBoundingClientRect()` of the positioned wrapper. This keeps the menu attached to the cursor position during scroll.

### Item selection

The `onSelect` callback in each menu item calls `payload.respond({ text, mode: "replace" })` which replaces the trigger range (`[[query`) with the full syntax (`[[Selected Item]]`). The `trigger` from the payload is included in the replacement text so delimiters aren't stripped.

---

## Code Block Navigation Architecture

Code blocks use a **delegate pattern** to decouple CM6 from PM document mutations. This is necessary because `stopEvent()` returns `true` in CodeBlockView — PM never sees keystrokes inside the node view.

### Data flow

```
CM6 keymap handler
       ↓
CodeBlockView emits intent via CodeBlockNavigationDelegate
       ↓
EditorBlock.vue handles the delegate (creates PM transactions)
       ↓
(planned) Editor shell → insertion zones replace direct mutations
```

### `CodeBlockNavigationDelegate` interface

Defined in `packages/editor/src/composables/useCodeBlockView.ts:23`:

```typescript
interface CodeBlockNavigationDelegate {
  requestNavigateOut(direction: "up" | "down", nodePos: number): void
  requestRemoveBlock(nodePos: number): void
  requestChangeLanguage(newLanguage: string, body: string, nodePos: number): void
}
```

Each method receives `nodePos` — the PM document position of the `code_block` node at call time — so the delegate can resolve neighbours without depending on PM's `NodeView.getPos()` lifecycle.

### Responsibility split

| Layer | Role |
|---|---|
| **CM6 keymap** | Detects boundary condition (cursor at first/last char, empty doc) and calls the delegate. |
| **CodeBlockView** | Constructs the CM6 EditorState, syncs content bidirectionally, delegates navigation intent. Never mutates PM document structure directly. |
| **EditorBlock.vue** | Creates the delegate closure with access to the PM `EditorView`. Dispatches PM transactions to implement navigation, removal, and language changes. |
| **(future) Editor shell** | Will own insertion zones (click targets between blocks) and handle navigation to empty zones by materializing paragraphs. |

### Document boundary rules

All materialization of paragraphs at boundaries is **temporary** — handled in EditorBlock.vue behind `// FIXME: Replace with Editor shell insertion zone.` markers.

| Direction | Boundary | Behavior |
|---|---|---|
| ArrowUp | Code block is first node (`nodePos === 0`) | Insert paragraph at position 0, focus it. |
| ArrowUp | Neighbour paragraph exists before | Exit to it via `TextSelection.near` / `GapCursor`. |
| ArrowDown | Code block is last node (`targetPos > doc.content.size`) | Insert paragraph at end of doc, focus it. |
| ArrowDown | Neighbour paragraph exists after | Exit to it via `TextSelection.near` / `GapCursor`. |
| Backspace | Code block is empty (`body.length === 0`) | Remove block (replace with paragraph if single child). |
| Backspace | Code block has content | Normal CM6 deletion — no delegate call. |

### Fence separation

Code block content is stored in PM as a single text node with fence markers (`` ```language\n`` … ``\n``` ``). The CM6 EditorState receives only the **body** via `stripFences()` — fences are stripped on the way in and re-added on the way out via `addFences()`.

```typescript
// PM node text:  "```typescript\nconst x = 1\n```\n"
// CM6 doc:       "const x = 1\n"
const body = stripFences(pmNode.textContent).body
// ...
const fenced = addFences(cmBody, language) // "```typescript\nconst x = 1\n```\n"
```

This prevents CM6 from parsing fence backticks as template literals.

---

## Performance Constraints

- Target **< 16ms per parse cycle** (60fps budget). The parser runs on every keystroke in focused `EditorBlock.vue` instances.
- `visitTree` processes each Lezer node exactly once. Do not introduce nested tree walks.
- Avoid allocations inside hot paths — reuse range objects where possible.
- **No incremental Lezer parsing.** `invalidateChangedCaches` always passes `undefined` as the old tree. Incremental parse fragments prevent GFM from re-classifying `Paragraph` → `Table` when the delimiter line is typed later. Full re-parse is negligible for blocks under 100 chars.

---

## Environment

> **GNU sed, not BSD sed.** This is macOS but `sed` refers to GNU sed (installed via Homebrew). Use `sed -i` without an empty-string argument for in-place edits, e.g. `sed -i 's/foo/bar/' file`. Do not use `sed -i ''` (that's BSD sed syntax).

---

## Critical Constraints

> **Read-only packed files.** Aggregated repo views from tools like Repomix or `workspace-pack` are transport-only. Never write output targeting packed container files. Always emit changes to original source paths under `apps/` or `packages/`.

> **Rule isolation.** Never modify `MarkdownRuleEngine.parse()` to add syntax support. New syntax belongs in a new `InlineRule` or `BlockRule` factory, registered in the constructor.

> **Test coverage is required.** A rule without a mirror test in `__tests__/markdown-parser.test.ts` is not complete.

> **Strict types.** Do not introduce `any`. If a type is genuinely unknown, model it explicitly with a discriminated union or `unknown` with a type guard.

---

## E2E Test Utilities (`__testUtils__`)

Injected in `Editor.vue` when `testMode` prop is true (gated by `?e2e=true`).

### `selectText` position convention

`selectText(blockId, from, to)` treats `from`/`to` as **0-based character offsets** within the block's content string (what `docToContent` returns). Internally these are mapped to PM positions by iterating through all `textblock` nodes via `doc.descendants()` and accounting for `\n\n` separators between paragraphs. This correctly handles multi-paragraph blocks (e.g. `doc(paragraph("abc"), paragraph("def"))` maps char offset 5 to PM position 6 = `"d"` in the second paragraph). Position 0 is before the first paragraph node and resolves outside any textblock, causing `currentBlock()` to return `null`.

### Caret initialization — `focusAtEnd` / `focusAtStart`

`page.keyboard.type()` inserts at the cursor. But `focus()` on a contenteditable leaves the cursor at a browser-dependent position (often position 0), and `press("End")` only reaches line-end, not doc-end. This causes text to land at the wrong position in a block.

Use `focusAtEnd(blockId)` or `focusAtStart(blockId)` instead to set cursor position via PM's selection API before typing:

```typescript
const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
const lastId = blockIds![blockIds!.length - 1]
await page.evaluate((bid: string) => window.__testUtils__?.focusAtEnd(bid), lastId)
await page.keyboard.type("hello world")   // reliably appends at end of block
```

Both helpers call `view.focus()` + `view.dispatch(tr.setSelection(...))` — absolute position, no browser quirk.

### When to use `page.keyboard.type()` vs `__testUtils__`

| Action | Method | Reason |
|---|---|---|
| Set exact text content | `insertText()` | Fast, bypasses plugins |
| **Replace entire block content** | **`replaceContent()`** | **Single PM transaction — deterministic undo history** |
| Select precise range | `selectText()` | Unreliable via Playwright in contenteditable |
| Caret positioning before typing | `focusAtEnd()` / `focusAtStart()` | Browser `focus()` + `End` is unreliable |
| Typing `[[`, `((`, `/`, `#` | `page.keyboard.type()` | Must go through `handleTextInput` → pattern plugin |
| Typing `(` for auto-close | `page.keyboard.type()` | Must go through auto-close handler |
| Typing `` ``` `` | `page.keyboard.type()` | Must go through `tripleBacktickRule` |
| Paste | `page.keyboard.press('Control+V')` | Must go through paste plugin |

---

## D4 — Page Model (Tauri app)

The `@enesis/tauri` app adds page-level navigation and editing on top of the core editor. The page model lives in `apps/tauri/src/`.

### Route architecture

A single catch-all route `[...slug].vue` maps workspace-relative file paths to views:

| Route | Classifier | Component |
|---|---|---|
| `/` | `index.vue` | Journal feed (unchanged) |
| `/journals/2026-06-22.md` | starts with `journals/`, has `.md` | `PageView` |
| `/pages/Architecture.md` | default case | `PageView` |
| `/tag/design` | starts with `tag/`, no `.md` | `TagView` |
| `/Readme.md` | default case | `PageView` |

The file path IS the URL — no fragile ID scheme. `encodeURIComponent` is used for path segments in routes.

### Data flow

```
Click [[Page Name]] in editor
  ↓
EditorBlock click handler (packages/editor/src/components/EditorBlock.vue:756)
  emits "page-ref-clicked" { pageName, alias }
  ↓
Event bubbles through Editor → JournalDay → JournalView (tauri)
  ↓
usePageNavigation().navigateToPage(pageName)
  ├── Look up title in SQLite index → SELECT path FROM pages WHERE title = ?
  ├── If found: router.push(`/${path}`)
  └── If not found: construct conventional path (pages/<safeName>.md), push.
                     No file is created on disk. (D4 change: creation is lazy.)
```

### Page auto-save flow (`pages/[...slug].vue`)

```
User types in editor
  ↓
localContent ref in PageView updates via v-model:content
  ↓
watch(localContent) → emit("update:content", val)
  ↓
[...slug].vue sets content = $event
  ↓
watch(content) → 500ms debounce → fs.writeFile(fullPath, val)
                                    ↓
                               existsOnDisk = true
                                    ↓
                               ws.indexFile(relativePath, val)
                                    ↓
                               scanFiles() → sidebar refreshes
```

Key behaviors:
- **No file on mount.** If the file doesn't exist on disk (`readFile` throws), `existsOnDisk` stays `false`. Empty content on a non-existent file is never written.
- **Debounced at 500ms.** Pending saves are flushed on unmount (`onUnmounted`) so quick navigation doesn't lose data.
- **Renaming** renames the physical file via `@tauri-apps/plugin-fs`. If no content exists yet, just navigates to the new path.

### Index two-flow

The indexer operates on two paths:

| | `syncIndex` (startup) | `fullReindex` (File → Reindex Library) |
|---|---|---|
| **Intent** | Fast incremental sync | Complete rebuild |
| **Wipes first?** | No — keeps existing data | Yes — `DELETE FROM pages` |
| **Unchanged files** | Skip (hash match) | Always re-indexed |
| **Deleted files** | Cleaned up | Cleaned up |
| **When** | Every app launch | User-initiated (`Cmd+Shift+R`) |

**`syncIndex`** reads every `.md` file, computes its djb2 content hash, and compares against `pages.content_hash`. If the hash matches, the file is skipped entirely — no `splitMarkdownIntoBlocks`, no block diff, no SQL writes. Files not on disk are cascade-removed from the index.

> **Watcher race:** The file watcher starts before sync completes. If a file changes mid-sync, the watcher fires independently. Worst case: a stale entry that corrects on the next change.

### Indexer title fallback

`lib/indexer.ts` uses `computePageTitle()` from `lib/parse.ts` to guarantee every indexed page has a non-empty title:

```
extractTitle(content) → first # Heading 1, or ""
  ↓
"" → filePath.split("/").pop().replace(".md", "")  → filename stem
  ↓
Always non-empty → stored in pages.title
```

This makes two critical queries reliable:
1. **Sidebar** (`useSidebarIndex`): `SELECT title FROM pages WHERE title != ''` — no page is invisible.
2. **Nav lookup** (`usePageNavigation`): `SELECT path FROM pages WHERE title = ?` — `[[New Page]]` resolves correctly even without a heading.

### Component responsibility split

| Component | Role | Props in | Events out |
|---|---|---|---|
| `[...slug].vue` | Route controller: classify, load file, query backlinks, auto-save, rename | route params | — |
| `PageView.vue` | Presentation: title input, editor, backlinks panel | `path`, `filename`, `content`, `backlinks` | `update:content`, `rename`, `backlink-clicked` |
| `TagView.vue` | Virtual tag aggregation from index | `tag` | — |
| `usePageNavigation` | Shared nav logic | — | — |

### Notable edge cases (documented in JSDoc)

- No mtime check on save — auto-save overwrites without conflict detection
- `modified_at` is index-time (not file mtime), making block ordering unreliable
- Excerpt `String.slice()` can split 4-byte Unicode characters
- Rename doesn't update existing `[[links]]` in other files
- Backlink counts are stale after rename (links store title at time of creation)
- TagView has no pagination (50-result LIMIT)

### Running tests

```bash
pnpm --filter @enesis/tauri test    # or vitest run in apps/tauri
biome check apps/tauri/src/         # run from project root
```
