diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx
new file mode 100644
index 0000000..4777c0c
--- /dev/null
+++ b/src/components/Navigation.tsx
@@ -0,0 +1,126 @@
+/**
+ * Navigation - Top navigation bar with keyboard shortcuts
+ *
+ * Provides navigation between Focus, Queue, Inbox, Settings, and Debug views.
+ * Supports keyboard shortcuts (Cmd+1/2/3/,) for quick navigation.
+ */
+
+import { useNavStore, type ViewId } from "@/stores/nav-store";
+import { useFocusStore } from "@/stores/focus-store";
+import { useInboxStore } from "@/stores/inbox-store";
+import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
+
+interface NavItem {
+ id: ViewId;
+ label: string;
+ shortcutKey: string;
+ badgeType?: "queue" | "inbox";
+}
+
+const NAV_ITEMS: NavItem[] = [
+ { id: "focus", label: "Focus", shortcutKey: "1" },
+ { id: "queue", label: "Queue", shortcutKey: "2", badgeType: "queue" },
+ { id: "inbox", label: "Inbox", shortcutKey: "3", badgeType: "inbox" },
+ { id: "debug", label: "Debug", shortcutKey: "4" },
+];
+
+export function Navigation(): React.ReactElement {
+ const activeView = useNavStore((s) => s.activeView);
+ const setView = useNavStore((s) => s.setView);
+ const current = useFocusStore((s) => s.current);
+ const queue = useFocusStore((s) => s.queue);
+ const inboxItems = useInboxStore((s) => s.items);
+
+ // Badge counts
+ const queueCount = (current ? 1 : 0) + queue.length;
+ const inboxCount = inboxItems.filter((i) => !i.triaged).length;
+
+ // Register keyboard shortcuts
+ useKeyboardShortcuts({
+ "mod+1": () => setView("focus"),
+ "mod+2": () => setView("queue"),
+ "mod+3": () => setView("inbox"),
+ "mod+4": () => setView("debug"),
+ "mod+,": () => setView("settings"),
+ });
+
+ function getBadgeCount(badgeType?: "queue" | "inbox"): number {
+ if (badgeType === "queue") return queueCount;
+ if (badgeType === "inbox") return inboxCount;
+ return 0;
+ }
+
+ function getBadgeClasses(badgeType?: "queue" | "inbox"): string {
+ if (badgeType === "inbox") {
+ return "rounded-full bg-amber-600/30 px-1.5 py-0.5 text-[10px] text-amber-400";
+ }
+ return "rounded-full bg-zinc-700 px-1.5 py-0.5 text-[10px] text-zinc-400";
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx
new file mode 100644
index 0000000..fec03f9
--- /dev/null
+++ b/src/components/SettingsView.tsx
@@ -0,0 +1,19 @@
+/**
+ * SettingsView - Application settings and preferences.
+ *
+ * Placeholder for Phase 5 implementation.
+ */
+
+export function SettingsView(): React.ReactElement {
+ return (
+
+
+
Settings
+
Coming in Phase 5
+
+
+ );
+}
diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 0000000..98449bf
--- /dev/null
+++ b/src/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,125 @@
+/**
+ * useKeyboardShortcuts - Global keyboard shortcut handler
+ *
+ * Provides a declarative way to register keyboard shortcuts that work
+ * across the entire app. Supports mod+key format where "mod" maps to
+ * Cmd on Mac and Ctrl on other platforms.
+ *
+ * Ignores shortcuts when typing in input fields, textareas, or contenteditable.
+ */
+
+import { useEffect, useCallback } from "react";
+
+/**
+ * Map of shortcut patterns to handlers.
+ *
+ * Pattern format: "mod+" where mod = Cmd (Mac) or Ctrl (other)
+ * Examples: "mod+1", "mod+,", "mod+k"
+ */
+export type ShortcutMap = Record void>;
+
+/**
+ * Check if an element is editable (input, textarea, contenteditable)
+ */
+function isEditableElement(element: Element | null): boolean {
+ if (!element) return false;
+
+ const tagName = element.tagName.toLowerCase();
+ if (tagName === "input" || tagName === "textarea") {
+ return true;
+ }
+
+ if (element instanceof HTMLElement) {
+ // Check multiple ways (browser vs JSDOM compatibility):
+ // - isContentEditable (browser property)
+ // - contentEditable property (works in JSDOM when set via property)
+ // - getAttribute (works when set via setAttribute)
+ if (
+ element.isContentEditable ||
+ element.contentEditable === "true" ||
+ element.getAttribute("contenteditable") === "true"
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Check if the event originated from an editable element
+ */
+function isFromEditableElement(event: KeyboardEvent): boolean {
+ // Check event target first (where the event originated)
+ if (event.target instanceof Element && isEditableElement(event.target)) {
+ return true;
+ }
+
+ // Also check activeElement as fallback
+ return isEditableElement(document.activeElement);
+}
+
+/**
+ * Parse a shortcut pattern and check if it matches the keyboard event.
+ */
+function matchesShortcut(pattern: string, event: KeyboardEvent): boolean {
+ const parts = pattern.toLowerCase().split("+");
+ const key = parts.pop();
+ const modifiers = new Set(parts);
+
+ // Check if key matches
+ if (event.key.toLowerCase() !== key) {
+ return false;
+ }
+
+ // "mod" means Cmd on Mac, Ctrl elsewhere
+ const hasModModifier = modifiers.has("mod");
+ if (hasModModifier) {
+ // Accept either metaKey (Cmd) or ctrlKey (Ctrl)
+ if (!event.metaKey && !event.ctrlKey) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Hook to register global keyboard shortcuts.
+ *
+ * @param shortcuts - Map of shortcut patterns to handler functions
+ *
+ * @example
+ * useKeyboardShortcuts({
+ * "mod+1": () => setView("focus"),
+ * "mod+2": () => setView("queue"),
+ * "mod+,": () => setView("settings"),
+ * });
+ */
+export function useKeyboardShortcuts(shortcuts: ShortcutMap): void {
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ // Don't handle shortcuts when typing in an editable element
+ if (isFromEditableElement(event)) {
+ return;
+ }
+
+ for (const [pattern, handler] of Object.entries(shortcuts)) {
+ if (matchesShortcut(pattern, event)) {
+ event.preventDefault();
+ handler();
+ return;
+ }
+ }
+ },
+ [shortcuts]
+ );
+
+ useEffect(() => {
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [handleKeyDown]);
+}
diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx
index b2eae94..199073c 100644
--- a/tests/components/AppShell.test.tsx
+++ b/tests/components/AppShell.test.tsx
@@ -90,7 +90,7 @@ describe("AppShell", () => {
});
renderWithProviders();
- expect(screen.getByText("3")).toBeInTheDocument();
+ expect(screen.getByTestId("queue-badge")).toHaveTextContent("3");
});
it("opens quick capture overlay on global shortcut event", async () => {
diff --git a/tests/components/Navigation.test.tsx b/tests/components/Navigation.test.tsx
new file mode 100644
index 0000000..27d0e07
--- /dev/null
+++ b/tests/components/Navigation.test.tsx
@@ -0,0 +1,159 @@
+/**
+ * Tests for Navigation component
+ *
+ * Tests navigation UI elements, active state, badge counts, and keyboard shortcuts.
+ */
+
+import { describe, it, expect, beforeEach } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Navigation } from "@/components/Navigation";
+import { useNavStore } from "@/stores/nav-store";
+import { useFocusStore } from "@/stores/focus-store";
+import { useInboxStore } from "@/stores/inbox-store";
+import { makeFocusItem } from "../helpers/fixtures";
+
+describe("Navigation", () => {
+ beforeEach(() => {
+ useNavStore.setState({ activeView: "focus" });
+ useFocusStore.setState({
+ current: null,
+ queue: [],
+ isLoading: false,
+ error: null,
+ });
+ useInboxStore.setState({
+ items: [],
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it("renders nav items for all views", () => {
+ render();
+
+ expect(screen.getByRole("button", { name: /focus/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /queue/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /inbox/i })).toBeInTheDocument();
+ });
+
+ it("highlights the active view", () => {
+ useNavStore.setState({ activeView: "queue" });
+
+ render();
+
+ const queueButton = screen.getByRole("button", { name: /queue/i });
+ expect(queueButton).toHaveAttribute("data-active", "true");
+
+ const focusButton = screen.getByRole("button", { name: /focus/i });
+ expect(focusButton).toHaveAttribute("data-active", "false");
+ });
+
+ it("shows queue badge count when items exist", () => {
+ useFocusStore.setState({
+ current: makeFocusItem({ id: "a" }),
+ queue: [makeFocusItem({ id: "b" }), makeFocusItem({ id: "c" })],
+ });
+
+ render();
+
+ const badge = screen.getByTestId("queue-badge");
+ expect(badge).toHaveTextContent("3");
+ });
+
+ it("does not show queue badge when no items", () => {
+ render();
+
+ expect(screen.queryByTestId("queue-badge")).not.toBeInTheDocument();
+ });
+
+ it("navigates to queue view on click", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: /queue/i }));
+
+ expect(useNavStore.getState().activeView).toBe("queue");
+ });
+
+ it("navigates to inbox view on click", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: /inbox/i }));
+
+ expect(useNavStore.getState().activeView).toBe("inbox");
+ });
+
+ it("navigates to settings view on settings button click", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: /settings/i }));
+
+ expect(useNavStore.getState().activeView).toBe("settings");
+ });
+
+ // Keyboard shortcut tests
+ function dispatchKeyEvent(
+ key: string,
+ opts: { metaKey?: boolean } = {}
+ ): void {
+ const event = new KeyboardEvent("keydown", {
+ key,
+ metaKey: opts.metaKey ?? false,
+ bubbles: true,
+ });
+ document.dispatchEvent(event);
+ }
+
+ it("navigates to focus view on Cmd+1", () => {
+ useNavStore.setState({ activeView: "queue" });
+ render();
+
+ act(() => {
+ dispatchKeyEvent("1", { metaKey: true });
+ });
+
+ expect(useNavStore.getState().activeView).toBe("focus");
+ });
+
+ it("navigates to queue view on Cmd+2", () => {
+ render();
+
+ act(() => {
+ dispatchKeyEvent("2", { metaKey: true });
+ });
+
+ expect(useNavStore.getState().activeView).toBe("queue");
+ });
+
+ it("navigates to inbox view on Cmd+3", () => {
+ render();
+
+ act(() => {
+ dispatchKeyEvent("3", { metaKey: true });
+ });
+
+ expect(useNavStore.getState().activeView).toBe("inbox");
+ });
+
+ it("navigates to settings view on Cmd+,", () => {
+ render();
+
+ act(() => {
+ dispatchKeyEvent(",", { metaKey: true });
+ });
+
+ expect(useNavStore.getState().activeView).toBe("settings");
+ });
+
+ it("displays keyboard shortcut hints", () => {
+ render();
+
+ // Check for shortcut hints in the nav items
+ expect(screen.getByText("1")).toBeInTheDocument();
+ expect(screen.getByText("2")).toBeInTheDocument();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ });
+});
diff --git a/tests/hooks/useKeyboardShortcuts.test.ts b/tests/hooks/useKeyboardShortcuts.test.ts
new file mode 100644
index 0000000..9ffcb63
--- /dev/null
+++ b/tests/hooks/useKeyboardShortcuts.test.ts
@@ -0,0 +1,203 @@
+/**
+ * Tests for useKeyboardShortcuts hook
+ *
+ * Verifies keyboard shortcut handling for navigation and actions.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useKeyboardShortcuts, type ShortcutMap } from "@/hooks/useKeyboardShortcuts";
+
+describe("useKeyboardShortcuts", () => {
+ // Helper to dispatch keyboard events
+ function dispatchKeyEvent(
+ key: string,
+ opts: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {}
+ ): void {
+ const event = new KeyboardEvent("keydown", {
+ key,
+ metaKey: opts.metaKey ?? false,
+ ctrlKey: opts.ctrlKey ?? false,
+ shiftKey: opts.shiftKey ?? false,
+ bubbles: true,
+ });
+ document.dispatchEvent(event);
+ }
+
+ it("calls handler when shortcut is pressed (meta key on Mac)", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+1": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ dispatchKeyEvent("1", { metaKey: true });
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls handler when shortcut is pressed (ctrl key fallback)", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+2": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ dispatchKeyEvent("2", { ctrlKey: true });
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not call handler when wrong key is pressed", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+1": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ dispatchKeyEvent("2", { metaKey: true });
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("does not call handler when modifier is missing", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+1": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ dispatchKeyEvent("1"); // No modifier
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("handles comma shortcut (mod+,)", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+,": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ dispatchKeyEvent(",", { metaKey: true });
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+
+ it("supports multiple shortcuts", () => {
+ const handler1 = vi.fn();
+ const handler2 = vi.fn();
+ const handler3 = vi.fn();
+
+ const shortcuts: ShortcutMap = {
+ "mod+1": handler1,
+ "mod+2": handler2,
+ "mod+3": handler3,
+ };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ dispatchKeyEvent("1", { metaKey: true });
+ dispatchKeyEvent("3", { metaKey: true });
+
+ expect(handler1).toHaveBeenCalledTimes(1);
+ expect(handler2).not.toHaveBeenCalled();
+ expect(handler3).toHaveBeenCalledTimes(1);
+ });
+
+ it("removes listeners on unmount", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+1": handler };
+
+ const { unmount } = renderHook(() => useKeyboardShortcuts(shortcuts));
+ unmount();
+
+ dispatchKeyEvent("1", { metaKey: true });
+
+ expect(handler).not.toHaveBeenCalled();
+ });
+
+ it("ignores shortcuts when typing in input fields", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+1": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ // Create and focus an input element
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ input.focus();
+
+ // Dispatch from the input
+ const event = new KeyboardEvent("keydown", {
+ key: "1",
+ metaKey: true,
+ bubbles: true,
+ });
+ input.dispatchEvent(event);
+
+ expect(handler).not.toHaveBeenCalled();
+
+ // Cleanup
+ document.body.removeChild(input);
+ });
+
+ it("ignores shortcuts when typing in textarea", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+2": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ const textarea = document.createElement("textarea");
+ document.body.appendChild(textarea);
+ textarea.focus();
+
+ const event = new KeyboardEvent("keydown", {
+ key: "2",
+ metaKey: true,
+ bubbles: true,
+ });
+ textarea.dispatchEvent(event);
+
+ expect(handler).not.toHaveBeenCalled();
+
+ document.body.removeChild(textarea);
+ });
+
+ it("ignores shortcuts when contenteditable is focused", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+3": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ const div = document.createElement("div");
+ div.contentEditable = "true";
+ document.body.appendChild(div);
+ div.focus();
+
+ const event = new KeyboardEvent("keydown", {
+ key: "3",
+ metaKey: true,
+ bubbles: true,
+ });
+ div.dispatchEvent(event);
+
+ expect(handler).not.toHaveBeenCalled();
+
+ document.body.removeChild(div);
+ });
+
+ it("prevents default behavior when shortcut matches", () => {
+ const handler = vi.fn();
+ const shortcuts: ShortcutMap = { "mod+1": handler };
+
+ renderHook(() => useKeyboardShortcuts(shortcuts));
+
+ const event = new KeyboardEvent("keydown", {
+ key: "1",
+ metaKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+
+ const preventDefaultSpy = vi.spyOn(event, "preventDefault");
+ document.dispatchEvent(event);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ });
+});