# 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.

Documents, blocks, tags, and references form an implicit **knowledge graph**. The SQLite index is treated as knowledge graph infrastructure, not just search — every future feature (queries, backlinks, graph views, dashboards) reads from this layer. A formal `Entity`/`Relation` abstraction in `services/graph.ts` makes this explicit.

**Source of truth chain:** Markdown files on disk are the canonical source. The SQLite index is a projection of the files (rebuilt on startup and via file watcher). The graph layer is a projection of SQLite for query convenience. Each layer reads from the layer above it; nothing writes downstream and expects changes to propagate backward.

---

## Monorepo Structure

```
apps/dev/          Development and testing sandbox (Vue 3 + Vite + TypeScript)
packages/editor/   Core headless engine — parsers, rule registries, composables, services, 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.

---

## Knowledge Graph Layer

Documents, blocks, tags, and references form an implicit graph. This is made explicit via `Entity`/`Relation` types in `packages/editor/src/services/graph.ts`:

```typescript
export interface Entity {
  id: string
  kind: "page" | "block" | "tag"
  title?: string
  path?: string
}

export interface Relation {
  from: string
  to: string
  type: "references" | "contains" | "tagged"
}
```

The SQLite index (`blocks`, `pages`, `links` tables) stores this graph as a projection of the markdown files. A `GraphService` in `apps/tauri/src/services/graph.ts` wraps index queries into typed `Entity[]`/`Relation[]` returns. All future features (queries, backlinks, graph views, dashboards) read from this layer.

The graph never writes back to SQLite or the filesystem. Every index entry can be regenerated from the files.

---

## Services Architecture

Domain logic lives in `services/` directories where a genuine subsystem emerges. Services are extracted when they solve a real problem (multiple consumers, clear boundary), not for organizational purity.

| Layer | Location | Purpose |
|---|---|---|
| Core types | `packages/editor/src/services/graph.ts` | Entity, Relation, Property, GraphProvider interface |
| App services | `apps/tauri/src/services/` | GraphService, SearchService (only when justified) |

**Rule:** If logic could be tested without mounting a Vue component, it *may* belong in a service. Prefer a composable that wraps a service once the service is independently useful. Do not extract services preemptively — let duplication drive the extraction.

**Source of truth chain:** Markdown files → SQLite index → Graph layer. Each is a projection of the one above. Nothing writes downstream and expects changes to propagate backward.

---

## 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`). Use services for domain logic (see Services Architecture). |
| 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.

---

## Comark Component Syntax

Comark extends CommonMark with component syntax via Lezer extensions in `@enesis/lezer-comark`. The editor wires these into the parser through `markdown-extensions.ts`:

```typescript
import { createComarkExtensions } from "@enesis/lezer-comark"

export function createMarkdownExtensions(customPatterns = []) {
  // ...
  return [merged, TaskBlock, ...createComarkExtensions()]
}
```

### Available syntaxes

| Syntax | Lezer node | Example |
|---|---|---|
| Block component | `BlockComponent` | `::query{type="TODO"}...::` |
| Inline component (attrs) | `InlineComponent` | `:priority{value="high"}` |
| Inline component (body+attrs) | `InlineComponent` | `:badge[Active]{color="green"}` |
| Standalone inline | `InlineComponent` | `:icon-check` |
| Span attributes | `Span` | `[text]{.highlight .yellow}` |

The `@enesis/lezer-comark` package is standalone — it has no dependency on the editor core or ProseMirror. It exports `createComarkExtensions()` which returns an array of `MarkdownConfig` objects. These are appended to the existing GFM + custom extensions in the editor's Lezer parser.

### Adding a component renderer

```typescript
import { registerComarkComponent } from "@/lib/comark-registry"
import type { SyntaxNode } from "@lezer/common"

registerComarkComponent("note", (node, ctx) => {
  // Return a BlockDecoration to render ::note{title="..."}...::
  // as a styled callout
})
```

Component renderers are lookup-only — the `MarkdownRuleEngine` produces `BlockComponent`/`InlineComponent` nodes, extracts the component name from `ComponentName`, and delegates to the registered renderer.

### Tests

The editor's test suite at `packages/editor/src/lib/__tests__/markdown-parser.test.ts` includes comark tests that verify the Lezer tree contains the expected nodes after parsing:

```typescript
const result = parseMarkdown("::query{type=\"TODO\"}\nResults\n::")
// result.tree contains BlockComponent, ComponentName, ComponentAttrs, ComponentBody
```

Tests for the grammar itself (position extraction, attribute parsing, multi-line bodies) live in `packages/lezer-comark/src/`.

---

## 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 `MarkdownRule<unknown>` 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/`.

> The SQLite index is treated as **knowledge graph infrastructure**, not just search. The `GraphService` in `apps/tauri/src/services/graph.ts` wraps index queries into `Entity[]`/`Relation[]` returns — every feature (queries, backlinks, graph views, dashboards) reads from this layer.

### 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
```
