diff --git a/apps/desktop/app/(app)/layout.tsx b/apps/desktop/app/(app)/layout.tsx
new file mode 100644
index 00000000..a4559e17
--- /dev/null
+++ b/apps/desktop/app/(app)/layout.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from "react"
+import { AppNav } from "@/components/app-nav"
+import { AuthGuard } from "@/components/auth-guard"
+import { Titlebar } from "@/components/titlebar"
+
+// Shell for the authenticated app (dashboard, settings). The login/ and
+// spotlight/ routes live OUTSIDE this group, so they don't inherit the nav +
+// guard chrome.
+export default function AppLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ )
+}
diff --git a/apps/desktop/app/(app)/page.tsx b/apps/desktop/app/(app)/page.tsx
new file mode 100644
index 00000000..ef0ff82d
--- /dev/null
+++ b/apps/desktop/app/(app)/page.tsx
@@ -0,0 +1,68 @@
+"use client"
+
+import { Button } from "@ui/components/button"
+import {
+ Card,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@ui/components/card"
+import { Search } from "lucide-react"
+import { SearchCommand, useCommandK } from "@/components/search-command"
+
+const MOCK_MEMORIES = [
+ { id: "1", title: "Q3 planning notes", desc: "Roadmap, OKRs, hiring plan" },
+ {
+ id: "2",
+ title: "Tauri vs Electron",
+ desc: "Why we picked Tauri for the desktop app",
+ },
+ {
+ id: "3",
+ title: "Onboarding flow",
+ desc: "Detect installed AI tools, one-click connect",
+ },
+ {
+ id: "4",
+ title: "SMFS notes",
+ desc: "One container, two interfaces (API + folder)",
+ },
+]
+
+export default function DashboardPage() {
+ const { open, setOpen } = useCommandK()
+
+ return (
+
+
+
+
Your memories
+
+ Everything you have saved, in one place.
+
+
+
setOpen(true)}>
+
+ Search
+ ⌘K
+
+
+
+
+ {MOCK_MEMORIES.map((memory) => (
+
+
+ {memory.title}
+ {memory.desc}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/apps/desktop/app/(app)/settings/page.tsx b/apps/desktop/app/(app)/settings/page.tsx
new file mode 100644
index 00000000..2ee7013d
--- /dev/null
+++ b/apps/desktop/app/(app)/settings/page.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import { invoke } from "@tauri-apps/api/core"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@ui/components/card"
+import Link from "next/link"
+import { useEffect, useState } from "react"
+
+type AppInfo = {
+ name: string
+ version: string
+ platform: string
+}
+
+export default function SettingsPage() {
+ const [info, setInfo] = useState(null)
+
+ useEffect(() => {
+ invoke("app_info")
+ .then(setInfo)
+ .catch(() => setInfo(null))
+ }, [])
+
+ return (
+
+
Settings
+
+
+
+ About
+
+ Native runtime details, read from the Rust core over IPC.
+
+
+
+
+
+
+
+
+
+
+ Not signed in?{" "}
+
+ Go to login
+
+
+
+ )
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
diff --git a/apps/desktop/app/globals.css b/apps/desktop/app/globals.css
index d8b6f430..2903e893 100644
--- a/apps/desktop/app/globals.css
+++ b/apps/desktop/app/globals.css
@@ -1,71 +1,20 @@
-:root {
- color-scheme: light dark;
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
- sans-serif;
-}
-
-* {
- box-sizing: border-box;
-}
+/*
+ * Desktop window chrome — supplements the shared @repo/ui theme, which is
+ * imported first in app/layout.tsx (`@ui/globals.css`) and provides the
+ * Tailwind layer + design tokens. Keep this file plain CSS (no @apply / no
+ * tailwind import) so there is a single Tailwind entry point.
+ */
html,
body {
height: 100%;
- margin: 0;
+ /* No rubber-band overscroll inside the native window. */
+ overscroll-behavior: none;
}
-.screen {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100vh;
- background: radial-gradient(125% 125% at 50% 0%, #1b1b2f 0%, #0c0c12 60%);
- color: #ececf1;
-}
-
-.card {
- width: 360px;
- padding: 2rem;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 1rem;
- background: rgba(255, 255, 255, 0.04);
- text-align: center;
-}
-
-.title {
- margin: 0;
- font-size: 1.5rem;
- font-weight: 600;
-}
-
-.muted {
- margin: 0.25rem 0 1.5rem;
- color: #9a9ab0;
- font-size: 0.875rem;
-}
-
-.info {
- display: grid;
- gap: 0.5rem;
- margin: 0;
-}
-
-.row {
- display: flex;
- justify-content: space-between;
- font-size: 0.875rem;
-}
-
-.row dt {
- color: #9a9ab0;
-}
-
-.row dd {
- margin: 0;
- font-variant-numeric: tabular-nums;
-}
-
-.error {
- color: #ff6b6b;
- font-size: 0.875rem;
+/* The draggable title bar should feel like native chrome, not selectable text. */
+[data-tauri-drag-region] {
+ cursor: default;
+ user-select: none;
+ -webkit-user-select: none;
}
diff --git a/apps/desktop/app/layout.tsx b/apps/desktop/app/layout.tsx
index 5752c0f8..a8c52ddb 100644
--- a/apps/desktop/app/layout.tsx
+++ b/apps/desktop/app/layout.tsx
@@ -1,15 +1,26 @@
+import type { Metadata } from "next"
+import { Space_Grotesk } from "next/font/google"
import type { ReactNode } from "react"
+import "@ui/globals.css"
import "./globals.css"
+import { Providers } from "./providers"
-export const metadata = {
+const font = Space_Grotesk({
+ subsets: ["latin"],
+ variable: "--font-sans",
+})
+
+export const metadata: Metadata = {
title: "Supermemory",
- description: "Supermemory Desktop",
+ description: "Your memories, wherever you are",
}
export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
- {children}
+
+
+ {children}
+
)
}
diff --git a/apps/desktop/app/login/page.tsx b/apps/desktop/app/login/page.tsx
new file mode 100644
index 00000000..349baea1
--- /dev/null
+++ b/apps/desktop/app/login/page.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import { Button } from "@ui/components/button"
+import { useRouter } from "next/navigation"
+
+// Phase 1 stub. Phase 3 wires "Sign in" to the system-browser OAuth flow
+// (invoke('auth_begin_oauth')) + the session-token deep-link handoff.
+export default function LoginPage() {
+ const router = useRouter()
+
+ return (
+
+ {/* Keep the frameless window draggable from the top edge. */}
+
+
+
+
Supermemory
+
+ Sign in to access your memories.
+
+
router.replace("/")}>
+ Sign in with browser
+
+
+ Browser-based sign-in arrives in a later phase.
+
+
+
+
+ )
+}
diff --git a/apps/desktop/app/page.tsx b/apps/desktop/app/page.tsx
deleted file mode 100644
index 1ddcb967..00000000
--- a/apps/desktop/app/page.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-"use client"
-
-import { invoke } from "@tauri-apps/api/core"
-import { useEffect, useState } from "react"
-
-type AppInfo = {
- name: string
- version: string
- platform: string
-}
-
-export default function Home() {
- const [info, setInfo] = useState(null)
- const [error, setError] = useState(null)
-
- useEffect(() => {
- // Call into the Rust core over Tauri's IPC bridge. If this resolves, the
- // native layer is alive and wired to the webview — the Phase 0 goal.
- invoke("app_info")
- .then(setInfo)
- .catch((err) => setError(String(err)))
- }, [])
-
- return (
-
-
- Supermemory
- Desktop · Phase 0
-
- {info ? (
-
-
-
App
- {info.name}
-
-
-
Version
- {info.version}
-
-
-
Platform
- {info.platform}
-
-
- ) : error ? (
- Native bridge error: {error}
- ) : (
- Connecting to the native core…
- )}
-
-
- )
-}
diff --git a/apps/desktop/app/providers.tsx b/apps/desktop/app/providers.tsx
new file mode 100644
index 00000000..6ade08dc
--- /dev/null
+++ b/apps/desktop/app/providers.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { Toaster } from "@ui/components/sonner"
+import { ThemeProvider } from "next-themes"
+import { type ReactNode, useState } from "react"
+
+export function Providers({ children }: { children: ReactNode }) {
+ // One QueryClient for the app's lifetime; lazy init avoids re-creating it on
+ // every render (and on Fast Refresh in dev).
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ staleTime: 60 * 1000,
+ },
+ },
+ }),
+ )
+
+ // The app is dark-only, matching apps/web (forcedTheme="dark").
+ return (
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/apps/desktop/app/spotlight/page.tsx b/apps/desktop/app/spotlight/page.tsx
new file mode 100644
index 00000000..b7eaead1
--- /dev/null
+++ b/apps/desktop/app/spotlight/page.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import { Search } from "lucide-react"
+
+// Phase 1 stub for the frameless spotlight window. Phase 5 wires the Rust
+// global-shortcut, window show/hide on blur/Esc, input focus on show, and real
+// /v3/search results.
+export default function SpotlightPage() {
+ return (
+
+ )
+}
diff --git a/apps/desktop/components/app-nav.tsx b/apps/desktop/components/app-nav.tsx
new file mode 100644
index 00000000..a1969d84
--- /dev/null
+++ b/apps/desktop/components/app-nav.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { Cog, House } from "lucide-react"
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+
+const LINKS = [
+ { href: "/", label: "Home", icon: House },
+ { href: "/settings", label: "Settings", icon: Cog },
+] as const
+
+export function AppNav() {
+ const pathname = usePathname()
+
+ return (
+
+ {LINKS.map(({ href, label, icon: Icon }) => {
+ const active = pathname === href
+ return (
+
+
+ {label}
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/desktop/components/auth-guard.tsx b/apps/desktop/components/auth-guard.tsx
new file mode 100644
index 00000000..a7d43269
--- /dev/null
+++ b/apps/desktop/components/auth-guard.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import { useRouter } from "next/navigation"
+import { type ReactNode, useEffect } from "react"
+
+// Phase 1 stub. Phase 2/3 replaces `useAuthStatus` with a real check of the
+// keychain-backed token (invoke('auth_get_token')) and returns "unauthenticated"
+// when it is missing. The state machine + redirect are wired now so later phases
+// only swap the source of truth, not the guard's shape.
+type AuthStatus = "loading" | "authenticated" | "unauthenticated"
+
+function useAuthStatus(): AuthStatus {
+ return "authenticated"
+}
+
+export function AuthGuard({ children }: { children: ReactNode }) {
+ const status = useAuthStatus()
+ const router = useRouter()
+
+ useEffect(() => {
+ if (status === "unauthenticated") {
+ router.replace("/login")
+ }
+ }, [status, router])
+
+ if (status !== "authenticated") {
+ return (
+
+ Loading…
+
+ )
+ }
+
+ return children
+}
diff --git a/apps/desktop/components/search-command.tsx b/apps/desktop/components/search-command.tsx
new file mode 100644
index 00000000..8f9941ef
--- /dev/null
+++ b/apps/desktop/components/search-command.tsx
@@ -0,0 +1,76 @@
+"use client"
+
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@ui/components/command"
+import { FileText, Hash, MessageSquare } from "lucide-react"
+import { useEffect, useState } from "react"
+
+// Mock data for Phase 1. Phase 5 swaps this for /v3/search results and wires the
+// OS-global hotkey via the Rust global-shortcut plugin. The point here is to
+// mount the real command-palette UI (cmdk + shared theme) behind an in-app
+// Cmd/Ctrl+K — the precursor to the spotlight window.
+const MOCK_RESULTS = [
+ { id: "1", title: "Q3 planning notes", kind: "doc" },
+ { id: "2", title: "Tauri vs Electron — research", kind: "doc" },
+ { id: "3", title: "engineering", kind: "space" },
+ { id: "4", title: "Desktop app architecture", kind: "chat" },
+] as const
+
+const ICONS = { doc: FileText, space: Hash, chat: MessageSquare }
+
+export function SearchCommand({
+ open,
+ onOpenChange,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}) {
+ return (
+
+
+
+ No results found.
+
+ {MOCK_RESULTS.map((result) => {
+ const Icon = ICONS[result.kind]
+ return (
+ onOpenChange(false)}
+ >
+
+ {result.title}
+
+ )
+ })}
+
+
+
+ )
+}
+
+// In-app Cmd/Ctrl+K toggles the palette. (The OS-global hotkey arrives in Phase 5
+// from the Rust side; this is the in-window shortcut.)
+export function useCommandK() {
+ const [open, setOpen] = useState(false)
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault()
+ setOpen((prev) => !prev)
+ }
+ }
+ document.addEventListener("keydown", onKeyDown)
+ return () => document.removeEventListener("keydown", onKeyDown)
+ }, [])
+
+ return { open, setOpen }
+}
diff --git a/apps/desktop/components/titlebar.tsx b/apps/desktop/components/titlebar.tsx
new file mode 100644
index 00000000..4b493c28
--- /dev/null
+++ b/apps/desktop/components/titlebar.tsx
@@ -0,0 +1,16 @@
+"use client"
+
+// Draggable strip for the frameless main window. On macOS the native traffic
+// lights overlay the top-left (tauri.conf.json -> titleBarStyle: "Overlay"),
+// so we pad left to clear them. `data-tauri-drag-region` is the hook that lets
+// Tauri move the OS window when this strip is dragged.
+export function Titlebar({ title = "Supermemory" }: { title?: string }) {
+ return (
+
+ )
+}
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 23dcba71..59dd4fbe 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -9,16 +9,23 @@
"tauri": "tauri"
},
"dependencies": {
+ "@repo/lib": "workspace:*",
+ "@repo/ui": "workspace:*",
+ "@tanstack/react-query": "^5.90.14",
"@tauri-apps/api": "^2",
+ "lucide-react": "^0.525.0",
"next": "^16.0.11",
+ "next-themes": "^0.4.6",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
+ "@tailwindcss/postcss": "^4.1.11",
"@tauri-apps/cli": "^2",
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
+ "tailwindcss": "^4.1.11",
"typescript": "^5.8.3"
}
}
diff --git a/apps/desktop/postcss.config.mjs b/apps/desktop/postcss.config.mjs
new file mode 100644
index 00000000..78452aad
--- /dev/null
+++ b/apps/desktop/postcss.config.mjs
@@ -0,0 +1,5 @@
+const config = {
+ plugins: ["@tailwindcss/postcss"],
+}
+
+export default config