Implement contract tests for CLI outputs

# Contract Tests for CLI Outputs

**Parent epic:** Phase 0: Test Infrastructure

**Purpose:** Detect schema drift in lore/br/bv CLI outputs before it breaks MC at runtime.

**Strategy:**
1. Fixtures contain real CLI outputs
2. Rust types with `#[serde(deny_unknown_fields)]` fail on unexpected fields
3. CI job regenerates fixtures and diffs against committed versions

**Contract test structure:**

```rust
// src-tauri/tests/contract_test.rs

#[test]
fn lore_me_response_matches_fixture() {
    let fixture = include_str!("fixtures/lore/me_with_reviews.json");

    // This will fail if:
    // 1. Required field is missing
    // 2. Field type changed
    // 3. Unknown field added (deny_unknown_fields)
    let result: Result<LoreMeResponse, _> = serde_json::from_str(fixture);

    assert!(result.is_ok(), "Fixture parse failed: {:?}", result.err());
}

#[test]
fn lore_me_empty_response_matches_fixture() {
    let fixture = include_str!("fixtures/lore/me_empty.json");
    let result: LoreMeResponse = serde_json::from_str(fixture)
        .expect("Empty response fixture should parse");

    assert!(result.data.since_last_check.is_empty());
}

#[test]
fn br_create_response_matches_fixture() {
    let fixture = include_str!("fixtures/br/create_success.json");
    let result: BrCreateResponse = serde_json::from_str(fixture)
        .expect("BR create fixture should parse");

    assert!(result.id.starts_with("bd-") || result.id.starts_with("br-"));
}

#[test]
fn br_list_response_matches_fixture() {
    let fixture = include_str!("fixtures/br/list.json");
    let result: Vec<BeadItem> = serde_json::from_str(fixture)
        .expect("BR list fixture should parse");

    assert!(!result.is_empty());
}

#[test]
fn bv_triage_response_matches_fixture() {
    let fixture = include_str!("fixtures/bv/triage.json");
    let result: BvTriageResponse = serde_json::from_str(fixture)
        .expect("BV triage fixture should parse");

    // Validate expected structure
    assert!(result.recommendations.is_some() || result.quick_ref.is_some());
}
```

**Type definitions with strict parsing:**

```rust
// src-tauri/src/data/types.rs

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]  // CRITICAL: Fails on new fields
pub struct LoreMeResponse {
    pub ok: bool,
    pub data: LoreMeData,
    #[serde(default)]
    pub meta: Option<LoreMeta>,
}

#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct LoreMeData {
    pub issues: Vec<LoreIssue>,
    pub mrs: LoreMrData,
    pub since_last_check: Vec<LoreEvent>,
}
```

**Fixture regeneration script:**

```bash
#!/bin/bash
# scripts/regenerate-fixtures.sh
set -e

FIXTURE_DIR="src-tauri/tests/fixtures"

echo "Regenerating CLI fixtures..."

# Lore fixtures
lore --robot me > "$FIXTURE_DIR/lore/me_current.json"
lore --robot me --issues > "$FIXTURE_DIR/lore/issues.json"
lore --robot me --mrs > "$FIXTURE_DIR/lore/mrs.json"

# BR fixtures
br list --json > "$FIXTURE_DIR/br/list.json"

# BV fixtures
bv --robot-triage > "$FIXTURE_DIR/bv/triage.json" 2>/dev/null || echo '{}' > "$FIXTURE_DIR/bv/triage.json"

echo "Fixtures regenerated. Run 'git diff' to see changes."
```

**CI job:**

```yaml
contract-tests:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

    - name: Install CLIs
      run: |
        cargo install lore br bv

    - name: Regenerate fixtures
      run: ./scripts/regenerate-fixtures.sh

    - name: Check for drift
      run: |
        if ! git diff --exit-code src-tauri/tests/fixtures/; then
          echo "::error::CLI output schema has changed! Update types and fixtures."
          exit 1
        fi

    - name: Run contract tests
      run: cargo test contract
```

**Acceptance criteria:**
- All fixture files parse with deny_unknown_fields
- Regeneration script works
- CI fails on schema drift
- Clear error messages for parsing failures

Completed: Contract tests in lore.rs/beads.rs


Implement keyboard shortcuts for Focus View

# Keyboard Shortcuts for Focus View (RED → GREEN)

**Parent epic:** Phase 3: Focus View

**Shortcuts:**
| Key | Action |
|-----|--------|
| Enter | Start (open in browser) |
| ⌘1 | Defer 1 hour |
| ⌘2 | Defer tomorrow |
| ⌘S | Skip |
| ⌘⇧C | Quick capture (global, but also here) |

**TDD: Keyboard tests (RED first):**

```typescript
// tests/components/FocusCard.test.tsx
describe('FocusCard keyboard shortcuts', () => {
  it('calls onStart when Enter pressed', async () => {
    const onStart = vi.fn();
    render(<FocusCard item={mockItem} onStart={onStart} />);

    await userEvent.keyboard('{Enter}');

    expect(onStart).toHaveBeenCalledWith(mockItem.id);
  });

  it('calls onDefer with 1h when ⌘1 pressed', async () => {
    const onDefer = vi.fn();
    render(<FocusCard item={mockItem} onDefer={onDefer} />);

    await userEvent.keyboard('{Meta>}1{/Meta}');

    expect(onDefer).toHaveBeenCalledWith(mockItem.id, '1h');
  });

  it('calls onDefer with tomorrow when ⌘2 pressed', async () => {
    const onDefer = vi.fn();
    render(<FocusCard item={mockItem} onDefer={onDefer} />);

    await userEvent.keyboard('{Meta>}2{/Meta}');

    expect(onDefer).toHaveBeenCalledWith(mockItem.id, 'tomorrow');
  });

  it('calls onSkip when ⌘S pressed', async () => {
    const onSkip = vi.fn();
    render(<FocusCard item={mockItem} onSkip={onSkip} />);

    await userEvent.keyboard('{Meta>}s{/Meta}');

    expect(onSkip).toHaveBeenCalledWith(mockItem.id);
  });

  it('does not trigger shortcuts when input focused', async () => {
    const onStart = vi.fn();
    render(
      <>
        <FocusCard item={mockItem} onStart={onStart} />
        <input data-testid="other-input" />
      </>
    );

    screen.getByTestId('other-input').focus();
    await userEvent.keyboard('{Enter}');

    expect(onStart).not.toHaveBeenCalled();
  });
});
```

**Implementation (GREEN):**

```typescript
// src/hooks/useKeyboard.ts
export function useKeyboardShortcuts(
  shortcuts: Record<string, () => void>,
  options: { enabled?: boolean } = {}
) {
  const { enabled = true } = options;

  useEffect(() => {
    if (!enabled) return;

    const handler = (e: KeyboardEvent) => {
      // Don't trigger in input/textarea
      if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
        return;
      }

      const key = buildKeyString(e);
      const action = shortcuts[key];
      if (action) {
        e.preventDefault();
        action();
      }
    };

    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [shortcuts, enabled]);
}

function buildKeyString(e: KeyboardEvent): string {
  const parts = [];
  if (e.metaKey || e.ctrlKey) parts.push('mod');
  if (e.shiftKey) parts.push('shift');
  if (e.altKey) parts.push('alt');
  parts.push(e.key.toLowerCase());
  return parts.join('+');
}
```

**Usage in FocusCard:**

```tsx
export function FocusCard({ item, onStart, onDefer, onSkip }) {
  useKeyboardShortcuts({
    'enter': () => onStart(item.id),
    'mod+1': () => onDefer(item.id, '1h'),
    'mod+2': () => onDefer(item.id, 'tomorrow'),
    'mod+s': () => onSkip(item.id),
  });

  // ... render
}
```

**Acceptance criteria:**
- All shortcut tests pass
- Shortcuts don't fire when typing in inputs
- Visual feedback shows shortcut hints on buttons
- Works on both Mac (⌘) and potential future Windows (Ctrl)

Done in FocusCard.tsx - Enter/Cmd+S shortcuts


Phase 0: Test Infrastructure

# Test Infrastructure Foundation

**Context:** Mission Control follows TDD (RED → GREEN → REFACTOR). Before any feature work, we need a complete testing foundation that supports both Rust backend tests and React frontend tests.

**Why this matters:**
- Every feature will be implemented RED → GREEN (failing test first, then implementation)
- CLI integration (lore, br, bv) requires fixture-based contract testing
- Crash recovery and state machine tests need trait-based mocking in Rust
- E2E tests need Playwright + Tauri integration

**Scope includes:**
1. Configure Vitest for frontend unit/component tests
2. Configure Playwright for E2E tests
3. Set up Rust test harness with trait-based mocking
4. Create fixture directory structure (tests/fixtures/lore/, tests/fixtures/br/)
5. Capture initial CLI fixtures from real lore/br outputs
6. Add test commands to package.json and Cargo.toml

**Duration estimate:** 0.5 day

**Success criteria:**
- `npm run test` runs Vitest with React Testing Library
- `npm run test:e2e` runs Playwright
- `cargo test` runs Rust tests with mocking infrastructure
- Fixture files exist with real CLI output samples
- Scripts exist to regenerate fixtures

**Architecture notes:**
- Rust uses trait-based mocking (LoreCli, BeadsCli traits)
- Frontend uses @tauri-apps/api/mocks for IPC mocking
- Fixtures capture real CLI outputs for contract testing
- CI will compare fresh fixture regeneration against committed fixtures

Completed: Vitest + Playwright + Rust mocks + contract tests all configured


Implement Inbox view with triage actions

# Inbox View with Triage Actions (RED → GREEN)

**Parent epic:** Phase 4: Queue + Inbox Views

**Purpose:** New items land in Inbox first for triage. Achievable inbox zero is the goal.

**Triage actions:**
| Action | Result |
|--------|--------|
| Accept | Move to Queue |
| Defer | Snooze for later |
| Archive | Mark as "not actionable for me" |

**TDD: Inbox tests (RED first):**

```typescript
// tests/components/Inbox.test.tsx
describe('Inbox', () => {
  const mockNewItems: WorkItem[] = [
    { id: '1', type: 'mention', title: 'You were mentioned in #312', triaged: false },
    { id: '2', type: 'mr_feedback', title: 'Comment on MR !847', triaged: false },
  ];

  it('shows only untriaged items', () => {
    const items = [...mockNewItems, { id: '3', triaged: true, ... }];
    render(<Inbox items={items} />);

    expect(screen.getAllByTestId('inbox-item')).toHaveLength(2);
  });

  it('shows inbox zero state when empty', () => {
    render(<Inbox items={[]} />);

    expect(screen.getByText(/Inbox Zero/)).toBeInTheDocument();
    expect(screen.getByText(/All caught up/)).toBeInTheDocument();
  });

  it('accept moves item to queue', async () => {
    const onTriage = vi.fn();
    render(<Inbox items={mockNewItems} onTriage={onTriage} />);

    await userEvent.click(screen.getAllByRole('button', { name: /accept/i })[0]);

    expect(onTriage).toHaveBeenCalledWith('1', 'accept');
  });

  it('defer shows duration picker', async () => {
    render(<Inbox items={mockNewItems} />);

    await userEvent.click(screen.getAllByRole('button', { name: /defer/i })[0]);

    expect(screen.getByRole('dialog')).toBeInTheDocument();
    expect(screen.getByText('1 hour')).toBeInTheDocument();
    expect(screen.getByText('Tomorrow')).toBeInTheDocument();
  });

  it('archive removes item from queue', async () => {
    const onTriage = vi.fn();
    render(<Inbox items={mockNewItems} onTriage={onTriage} />);

    await userEvent.click(screen.getAllByRole('button', { name: /archive/i })[0]);

    expect(onTriage).toHaveBeenCalledWith('1', 'archive');
  });

  it('keyboard shortcuts work for triage', async () => {
    const onTriage = vi.fn();
    render(<Inbox items={mockNewItems} onTriage={onTriage} />);

    // Focus first item
    await userEvent.tab();
    // Press A for Accept
    await userEvent.keyboard('a');

    expect(onTriage).toHaveBeenCalledWith('1', 'accept');
  });
});
```

**Implementation (GREEN):**

```tsx
// src/components/Inbox.tsx
export function Inbox({ items, onTriage }: InboxProps) {
  const untriagedItems = items.filter(i => !i.triaged);

  if (untriagedItems.length === 0) {
    return (
      <div className="flex flex-col items-center justify-center py-12">
        <CheckCircle className="w-16 h-16 text-green-500 mb-4" />
        <h2 className="text-2xl font-bold">Inbox Zero</h2>
        <p className="text-muted-foreground">All caught up!</p>
      </div>
    );
  }

  return (
    <div className="space-y-2">
      <h2 className="text-lg font-semibold">Inbox ({untriagedItems.length})</h2>

      {untriagedItems.map((item, index) => (
        <InboxItem
          key={item.id}
          item={item}
          onAccept={() => onTriage(item.id, 'accept')}
          onDefer={(duration) => onTriage(item.id, 'defer', duration)}
          onArchive={() => onTriage(item.id, 'archive')}
          shortcuts={index === 0 ? { accept: 'a', defer: 'd', archive: 'x' } : undefined}
        />
      ))}
    </div>
  );
}

function InboxItem({ item, onAccept, onDefer, onArchive, shortcuts }: InboxItemProps) {
  return (
    <Card className="p-4">
      <div className="flex items-start gap-4">
        <TypeIcon type={item.type} />

        <div className="flex-1">
          <h3 className="font-medium">{item.title}</h3>
          <p className="text-sm text-muted-foreground">{item.metadata?.snippet}</p>
        </div>

        <div className="flex gap-2">
          <Button size="sm" onClick={onAccept}>
            Accept {shortcuts?.accept && <kbd className="ml-1">{shortcuts.accept}</kbd>}
          </Button>
          <DeferPicker onSelect={onDefer} />
          <Button size="sm" variant="ghost" onClick={onArchive}>
            Archive {shortcuts?.archive && <kbd className="ml-1">{shortcuts.archive}</kbd>}
          </Button>
        </div>
      </div>
    </Card>
  );
}
```

**Acceptance criteria:**
- Only untriaged items shown
- Inbox zero celebration state
- Accept/Defer/Archive actions work
- Keyboard shortcuts for fast triage
- Triage decisions logged


Implement Settings UI

# Settings UI

**Parent epic:** Phase 7: Polish + E2E Tests

**Purpose:** Allow users to customize Mission Control behavior.

**Settings to include:**

| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| Toggle hotkey | Keybinding | ⌘⇧M | Customize window toggle |
| Capture hotkey | Keybinding | ⌘⇧C | Customize quick capture |
| Lore DB path | File path | ~/.local/share/lore/lore.db | Custom lore location |
| Reconciliation interval | Number | 6 | Hours between full reconciliation |
| Show floating widget | Boolean | false | Enable persistent mini-view |
| Default defer duration | Select | 1h | Default snooze time |
| Sound effects | Boolean | true | Celebration sounds |

**File location:** `~/.local/share/mc/settings.json`

**Schema:**
```json
{
  "schema_version": 1,
  "hotkeys": {
    "toggle": "Meta+Shift+M",
    "capture": "Meta+Shift+C"
  },
  "lore_db_path": null,
  "reconciliation_hours": 6,
  "floating_widget": false,
  "default_defer": "1h",
  "sounds": true
}
```

**TDD: Settings tests (RED first):**

```typescript
// tests/components/Settings.test.tsx
describe('Settings', () => {
  it('loads current settings on mount', async () => {
    render(<Settings />);

    await waitFor(() => {
      expect(screen.getByDisplayValue('⌘⇧M')).toBeInTheDocument();
    });
  });

  it('saves settings on change', async () => {
    const mockSave = vi.fn();
    render(<Settings onSave={mockSave} />);

    await userEvent.click(screen.getByLabelText('Sound effects'));

    expect(mockSave).toHaveBeenCalledWith(expect.objectContaining({
      sounds: false
    }));
  });

  it('validates hotkey format', async () => {
    render(<Settings />);

    await userEvent.clear(screen.getByLabelText('Toggle hotkey'));
    await userEvent.type(screen.getByLabelText('Toggle hotkey'), 'invalid');

    expect(screen.getByText(/Invalid hotkey format/)).toBeInTheDocument();
  });
});
```

**Implementation:**

```tsx
// src/views/SettingsView.tsx
export function SettingsView() {
  const { data: settings, mutate } = useSettings();

  return (
    <div className="p-6 max-w-lg mx-auto">
      <h1 className="text-2xl font-bold mb-6">Settings</h1>

      <div className="space-y-6">
        <section>
          <h2 className="text-lg font-semibold mb-3">Hotkeys</h2>
          <HotkeyInput
            label="Toggle window"
            value={settings.hotkeys.toggle}
            onChange={(v) => mutate({ ...settings, hotkeys: { ...settings.hotkeys, toggle: v }})}
          />
          <HotkeyInput
            label="Quick capture"
            value={settings.hotkeys.capture}
            onChange={(v) => mutate({ ...settings, hotkeys: { ...settings.hotkeys, capture: v }})}
          />
        </section>

        <section>
          <h2 className="text-lg font-semibold mb-3">Behavior</h2>
          <Switch
            label="Sound effects"
            checked={settings.sounds}
            onCheckedChange={(v) => mutate({ ...settings, sounds: v })}
          />
          <Switch
            label="Floating widget"
            checked={settings.floating_widget}
            onCheckedChange={(v) => mutate({ ...settings, floating_widget: v })}
          />
        </section>
      </div>
    </div>
  );
}
```

**Acceptance criteria:**
- All settings load and save correctly
- Hotkey changes take effect immediately
- Invalid inputs show validation errors
- Settings persist across app restarts


System tray icon with badge counts

# System Tray Icon Implementation

**Parent epic:** Phase 1: Foundation

**UX principle:** Ambient awareness, not interruption. The tray icon shows at-a-glance status without being modal.

**Implementation:**

1. **Tauri tray setup (src-tauri/src/tray.rs):**
   ```rust
   use tauri::{
       tray::{TrayIconBuilder, TrayIconEvent},
       Manager, Runtime,
   };

   pub fn setup_tray<R: Runtime>(app: &tauri::App<R>) -> Result<()> {
       let tray = TrayIconBuilder::new()
           .icon(app.default_window_icon().unwrap().clone())
           .tooltip("Mission Control")
           .on_tray_icon_event(|tray, event| {
               match event {
                   TrayIconEvent::Click { button: LeftButton, .. } => {
                       // Toggle popover
                   }
                   _ => {}
               }
           })
           .build(app)?;
       Ok(())
   }
   ```

2. **Badge count updates:**
   - Tray icon can show numeric badge (macOS native)
   - Update badge when work item count changes
   - Badge reflects: pending items + inbox items

3. **Icon assets:**
   - Create icons at multiple sizes (16x16, 32x32, 64x64)
   - Template image for macOS (works with light/dark menu bar)
   - Colored variants for badge states

4. **Menu on right-click:**
   - "Show Mission Control" (opens full window)
   - "Quick Capture" (opens capture overlay)
   - Separator
   - "Quit"

**Integration with frontend:**
- Tauri event to update badge count
- Frontend sends count updates via invoke

**Acceptance criteria:**
- Tray icon appears when app runs
- Left-click shows popover (or toggles window)
- Right-click shows context menu
- Badge count updates reflect actual pending items
- Works on macOS (primary target)

Completed: System tray icon with menu (Show/Quit), left-click toggles window


Implement menu bar popover

# Menu Bar Popover

**Parent epic:** Phase 1: Foundation

**Purpose:** Quick glance at THE ONE THING without opening full window. Click tray icon → popover appears.

**Visual design:**
```
                              ┌─────────────────────────────┐
                              │  THE ONE THING              │
                              │  Review MR !847             │
                              │  2d waiting · @sarah        │
                              │                             │
                              │  [Start]  [Defer]  [Skip]   │
                              ├─────────────────────────────┤
                              │  Queue: 4  Inbox: 3         │
                              │  ⌘⇧F Full window            │
                              └─────────────────────────────┘
```

**TDD: Popover tests (RED first):**

```typescript
// tests/components/Popover.test.tsx
describe('TrayPopover', () => {
  it('shows current focus item', () => {
    render(<TrayPopover focusItem={mockItem} />);

    expect(screen.getByText('THE ONE THING')).toBeInTheDocument();
    expect(screen.getByText('Review MR !847')).toBeInTheDocument();
  });

  it('shows queue and inbox counts', () => {
    render(<TrayPopover queueCount={4} inboxCount={3} />);

    expect(screen.getByText('Queue: 4')).toBeInTheDocument();
    expect(screen.getByText('Inbox: 3')).toBeInTheDocument();
  });

  it('shows empty state when no focus', () => {
    render(<TrayPopover focusItem={null} />);

    expect(screen.getByText(/Nothing focused/)).toBeInTheDocument();
  });

  it('Start action opens browser', async () => {
    const mockOpen = vi.fn();
    render(<TrayPopover focusItem={mockItem} onStart={mockOpen} />);

    await userEvent.click(screen.getByRole('button', { name: /start/i }));

    expect(mockOpen).toHaveBeenCalled();
  });
});
```

**Implementation:**

```tsx
// src/components/TrayPopover.tsx
interface TrayPopoverProps {
  focusItem: WorkItem | null;
  queueCount: number;
  inboxCount: number;
  onStart: () => void;
  onDefer: (duration: string) => void;
  onSkip: () => void;
  onOpenFull: () => void;
}

export function TrayPopover({
  focusItem,
  queueCount,
  inboxCount,
  onStart,
  onDefer,
  onSkip,
  onOpenFull
}: TrayPopoverProps) {
  return (
    <div className="w-72 p-4 bg-background rounded-lg shadow-lg">
      {focusItem ? (
        <>
          <p className="text-xs text-muted-foreground mb-1">THE ONE THING</p>
          <h3 className="font-semibold truncate">{focusItem.title}</h3>
          <p className="text-sm text-muted-foreground">
            {formatAge(focusItem.createdAt)} · {focusItem.metadata?.author}
          </p>

          <div className="flex gap-2 mt-3">
            <Button size="sm" onClick={onStart}>Start</Button>
            <Button size="sm" variant="outline" onClick={() => onDefer('1h')}>Defer</Button>
            <Button size="sm" variant="ghost" onClick={onSkip}>Skip</Button>
          </div>
        </>
      ) : (
        <p className="text-muted-foreground">Nothing focused. Pick something from the queue!</p>
      )}

      <Separator className="my-3" />

      <div className="flex justify-between text-sm">
        <span>Queue: {queueCount}</span>
        <span>Inbox: {inboxCount}</span>
      </div>

      <Button variant="link" size="sm" className="mt-2 w-full" onClick={onOpenFull}>
        ⌘⇧F Full window
      </Button>
    </div>
  );
}
```

**Tauri integration:**

```rust
// Popover window configuration in tauri.conf.json
{
  "windows": [
    {
      "label": "popover",
      "width": 288,
      "height": 200,
      "decorations": false,
      "alwaysOnTop": true,
      "visible": false,
      "skipTaskbar": true
    }
  ]
}
```

**Acceptance criteria:**
- Popover appears on tray icon click
- Shows THE ONE THING or empty state
- Actions work (Start/Defer/Skip)
- Shows queue and inbox counts
- Can open full window from popover


Register global hotkey for Quick Capture (⌘⇧C)

# Global Hotkey for Quick Capture

**Parent epic:** Phase 6: Quick Capture

**Requirement:** ⌘⇧C (Cmd+Shift+C) must work from ANY app, even when Mission Control is not focused.

**Implementation:**

```rust
// src-tauri/src/shortcuts.rs
pub fn setup_shortcuts<R: Runtime>(app: &tauri::App<R>) -> Result<()> {
    use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};

    // Toggle window (⌘⇧M)
    let toggle_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyM);

    // Quick capture (⌘⇧C)
    let capture_shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyC);

    app.global_shortcut().on_shortcut(capture_shortcut, |app, _| {
        // Show capture overlay
        if let Some(window) = app.get_webview_window("main") {
            // Make window visible if hidden
            if !window.is_visible().unwrap_or(false) {
                window.show().ok();
            }
            window.set_focus().ok();

            // Emit event to show capture overlay
            app.emit_all("show-quick-capture", ()).ok();
        }
    })?;

    Ok(())
}
```

**Frontend listener:**

```typescript
// src/App.tsx
function App() {
  const [showCapture, setShowCapture] = useState(false);

  useEffect(() => {
    const unlisten = listen('show-quick-capture', () => {
      setShowCapture(true);
    });

    return () => { unlisten.then(fn => fn()); };
  }, []);

  return (
    <>
      {/* ... other views */}
      <QuickCapture
        isOpen={showCapture}
        onSave={async (text) => {
          await invoke('quick_capture', { text });
          setShowCapture(false);
        }}
        onCancel={() => setShowCapture(false)}
      />
    </>
  );
}
```

**Window behavior:**
1. If MC is hidden → show window + show overlay
2. If MC is visible but overlay closed → show overlay
3. If overlay is open → do nothing (or close it?)

**Testing considerations:**
- Global shortcuts are hard to test automatically
- Manual testing required for cross-app behavior
- E2E test can verify overlay behavior after trigger

**Acceptance criteria:**
- ⌘⇧C works from any app
- Overlay appears within 200ms
- Window comes to front if needed
- No conflict with other common shortcuts


File watcher on lore.db for change detection

# File Watcher for Lore Database Changes (RED → GREEN)

**Parent epic:** Phase 1: Foundation

**Why file watching:**
Lore runs on a cron schedule (not triggered by MC). When lore syncs new data, MC should refresh automatically. We watch lore.db's mtime for changes rather than polling the CLI.

**TDD approach:**

1. **RED: Write file watcher tests:**
   ```rust
   // src-tauri/tests/watcher_test.rs
   #[tokio::test]
   async fn watcher_detects_mtime_change() {
       let temp = tempfile::tempdir().unwrap();
       let db_path = temp.path().join("lore.db");
       std::fs::write(&db_path, "initial").unwrap();

       let (tx, mut rx) = tokio::sync::mpsc::channel(1);
       let _watcher = FileWatcher::new(&db_path, tx);

       // Modify file
       std::fs::write(&db_path, "modified").unwrap();

       // Should receive change notification
       let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
           .await
           .expect("Should receive event")
           .expect("Channel shouldn't close");

       assert!(matches!(event, WatchEvent::Modified));
   }

   #[tokio::test]
   async fn watcher_debounces_rapid_changes() {
       // Multiple rapid writes should coalesce to single event
   }
   ```

2. **GREEN: Implement watcher:**
   ```rust
   // src-tauri/src/watcher.rs
   use notify::{Watcher, RecursiveMode, Config, RecommendedWatcher};

   pub struct LoreDbWatcher {
       _watcher: RecommendedWatcher,
   }

   impl LoreDbWatcher {
       pub fn new<F>(db_path: &Path, on_change: F) -> Result<Self>
       where
           F: Fn() + Send + 'static
       {
           let debounce_duration = Duration::from_millis(500);
           let config = Config::default()
               .with_poll_interval(Duration::from_secs(2));

           let mut watcher = RecommendedWatcher::new(
               move |res: Result<notify::Event, _>| {
                   if let Ok(event) = res {
                       if event.kind.is_modify() {
                           on_change();
                       }
                   }
               },
               config,
           )?;

           watcher.watch(db_path, RecursiveMode::NonRecursive)?;

           Ok(Self { _watcher: watcher })
       }
   }
   ```

3. **Integration with Tauri:**
   ```rust
   // When lore.db changes, emit event to frontend
   let watcher = LoreDbWatcher::new(&lore_db_path, move || {
       app_handle.emit_all("lore-db-changed", ()).ok();
   })?;
   ```

**Lore.db location:**
- Default: `~/.local/share/lore/lore.db`
- Should be configurable via settings

**Debouncing:**
- Lore writes may trigger multiple filesystem events
- Debounce to single refresh per 500ms window

**Acceptance criteria:**
- File changes detected within 2 seconds
- Rapid changes debounced to single event
- Frontend receives Tauri event on change
- Works with both native and poll-based watchers
- Graceful handling of missing/moved database file

Implemented in src-tauri/src/watcher.rs - watches lore.db mtime, emits 'lore-data-changed' event. 62 Rust tests pass.


Implement crash-safe write-ahead pattern with pending flag

# Crash Recovery with Write-Ahead Pattern (RED → GREEN)

**Parent epic:** Phase 2: Bridge + Data Layer

**Problem being solved:**
If MC crashes mid-sync, we risk:
- Duplicates: bead created but not mapped
- Lost events: cursor advanced but bead not created

**Solution:** Write-ahead pattern with idempotent operations.

**For each new event:**
```
1. Check if key exists in mapping → if yes, skip (idempotent)
2. Write mapping entry FIRST: {key} → {bead_id: null, pending: true}
3. Create bead via `br create`
4. Update mapping: {bead_id: actual_id, pending: false}
5. On success of all events: advance cursor
```

**Crash recovery (on startup):**
```
1. Scan mapping for entries with pending: true
2. For each pending entry:
   - If bead_id is null → retry `br create`, update mapping
   - If bead_id exists but pending → verify bead exists, clear pending flag
3. Do NOT advance cursor until all pending entries resolved
```

**TDD: Crash recovery tests (RED first):**

```rust
#[test]
fn crash_before_br_create_retries_on_startup() {
    // Simulate state: mapping written but br create never ran
    let mapping = mapping_with_pending("mr:gitlab:123:847", None);  // null bead_id
    let beads = MockBeadsCli::new();

    let bridge = BridgeState::recover(lore, beads, mapping)?;

    // Should have retried create
    assert!(beads.calls().contains(&BeadsCall::Create { .. }));

    // Should have updated mapping with real bead_id
    let entry = bridge.mapping.get("mr:gitlab:123:847").unwrap();
    assert!(entry.bead_id.is_some());
    assert!(!entry.pending);
}

#[test]
fn crash_after_br_create_verifies_bead_exists() {
    // Simulate state: bead created but pending flag not cleared
    let mapping = mapping_with_pending("mr:gitlab:123:847", Some("br-x7f"));
    let beads = MockBeadsCli::with_existing("br-x7f");

    let bridge = BridgeState::recover(lore, beads, mapping)?;

    // Should have verified bead exists
    assert!(beads.calls().contains(&BeadsCall::Exists { id: "br-x7f" }));

    // Should have cleared pending flag
    let entry = bridge.mapping.get("mr:gitlab:123:847").unwrap();
    assert!(!entry.pending);
}

#[test]
fn crash_after_br_create_but_bead_missing_retries() {
    // Edge case: br create succeeded but bead was somehow deleted
    let mapping = mapping_with_pending("mr:gitlab:123:847", Some("br-x7f"));
    let beads = MockBeadsCli::empty();  // bead doesn't exist

    let bridge = BridgeState::recover(lore, beads, mapping)?;

    // Should have created a new bead
    let entry = bridge.mapping.get("mr:gitlab:123:847").unwrap();
    assert!(entry.bead_id.is_some());
    assert_ne!(entry.bead_id.as_deref(), Some("br-x7f"));  // New ID
}

#[test]
fn cursor_not_advanced_until_pending_resolved() {
    let mapping = mapping_with_pending("mr:gitlab:123:847", None);
    let old_cursor = mapping.cursor.clone();
    let beads = MockBeadsCli::new();

    let bridge = BridgeState::recover(lore, beads, mapping)?;

    // Even if recovery succeeds, cursor should be what it was
    // (because we don't know what events were missed)
    assert_eq!(bridge.cursor.last_check_timestamp, old_cursor.last_check_timestamp);
}
```

**Implementation (GREEN):**

```rust
impl<L: LoreCli, B: BeadsCli> BridgeState<L, B> {
    pub fn recover(lore: L, beads: B, mapping: Mapping) -> Result<Self> {
        let mut bridge = Self { lore, beads, mapping };

        // Find all pending entries
        let pending: Vec<_> = bridge.mapping.iter()
            .filter(|(_, e)| e.pending)
            .map(|(k, e)| (k.clone(), e.clone()))
            .collect();

        for (key, entry) in pending {
            if let Some(bead_id) = &entry.bead_id {
                // bead_id exists — verify it's real
                if bridge.beads.exists(bead_id)? {
                    // Just clear pending flag
                    bridge.mapping.get_mut(&key).unwrap().pending = false;
                } else {
                    // Bead was lost — recreate
                    let new_id = bridge.beads.create(&key, "gitlab")?;
                    let e = bridge.mapping.get_mut(&key).unwrap();
                    e.bead_id = Some(new_id);
                    e.pending = false;
                }
            } else {
                // bead_id is null — create was never attempted
                let bead_id = bridge.beads.create(&key, "gitlab")?;
                let e = bridge.mapping.get_mut(&key).unwrap();
                e.bead_id = Some(bead_id);
                e.pending = false;
            }
        }

        // Save recovered mapping
        bridge.save_mapping()?;

        Ok(bridge)
    }

    pub fn create_bead_for_event(&mut self, event: &LoreEvent) -> Result<()> {
        let key = event.to_mapping_key();

        // Step 1: Idempotency check
        if self.mapping.contains_key(&key) {
            return Ok(());
        }

        // Step 2: Write pending entry FIRST
        self.mapping.insert(key.clone(), MappingEntry {
            bead_id: None,
            pending: true,
            ..Default::default()
        });
        self.save_mapping()?;

        // Step 3: Create bead
        let bead_id = self.beads.create(&event.title(), "gitlab")?;

        // Step 4: Update entry with bead_id, clear pending
        let entry = self.mapping.get_mut(&key).unwrap();
        entry.bead_id = Some(bead_id);
        entry.pending = false;
        self.save_mapping()?;

        Ok(())
    }
}
```

**Why this works:**
- Step 1 is idempotent (duplicate events skip)
- Step 2 happens before bead creation (we know we intend to create)
- Step 5 only advances cursor after ALL events processed
- Recovery finds incomplete work and finishes it

**Acceptance criteria:**
- All 4 crash recovery tests pass
- Pending flag correctly tracks in-flight operations
- Recovery handles all failure scenarios
- No duplicates or lost events possible