diff --git a/app/globals.css b/app/globals.css
index ac65329..e17e287 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -389,6 +389,30 @@ body {
}
}
+/* Settings dialog pop (settings-app wave, 2026-09-20): a short fade +
+ 2% settle-scale — understated by design, the app's motion taste
+ (200ms sidebar, 150ms here); honors reduced-motion by appearing. */
+@keyframes settings-pop {
+ from {
+ opacity: 0;
+ scale: 0.98;
+ }
+ to {
+ opacity: 1;
+ scale: 1;
+ }
+}
+
+.settings-dialog-pop {
+ animation: settings-pop 150ms ease-out;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .settings-dialog-pop {
+ animation: none;
+ }
+}
+
/* ─────────────────────────────────────────────────────────────────────────
Electron merged chrome (owner, 2026-09-17). The system title bar is
hidden (titleBarStyle: "hiddenInset" in electron/main.cjs); the app's
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
index 35ba7f4..1b68fa9 100644
--- a/app/settings/page.tsx
+++ b/app/settings/page.tsx
@@ -1,8 +1,11 @@
-/** The settings route (thin settings wave): server shell over the client
- * page — same shape as the chat routes (AppShell wraps via the root
- * layout, so the sidebar + its Settings entry come along). */
-import { SettingsPage } from "@/components/settings/settings-page.tsx";
+import { redirect } from "next/navigation";
-export default function Page() {
- return ;
-}
\ No newline at end of file
+/**
+ * The settings surface graduated into a centered dialog over the app
+ * (settings-app wave, 2026-09-20 — owner decision: modal-only, no page).
+ * The URL stays routed but redirects: nothing should land on a dead
+ * page; the dialog opens via the sidebar row or mod+,.
+ */
+export default function SettingsPage() {
+ redirect("/chat/new");
+}
diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx
index 5bbce11..2bcffb2 100644
--- a/components/keyboard-shortcuts.tsx
+++ b/components/keyboard-shortcuts.tsx
@@ -34,18 +34,21 @@ import {
KEYBOARD_ACTIONS,
} from "@/src/core";
import { isDictationActive } from "@/components/dictation";
+import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
export function KeyboardShortcuts() {
const router = useRouter();
const [cheatOpen, setCheatOpen] = useState(false);
+ const { open: openSettings } = useSettingsDialog();
- // Router + cheat state inside a ref so the keydown listener (attached
- // once) never goes stale.
+ // Router + cheat + settings state inside a ref so the keydown listener
+ // (attached once) never goes stale.
const handlersRef = useRef<{
router: ReturnType;
setCheatOpen: (v: boolean) => void;
- }>({ router, setCheatOpen });
- handlersRef.current = { router, setCheatOpen };
+ openSettings: () => void;
+ }>({ router, setCheatOpen, openSettings });
+ handlersRef.current = { router, setCheatOpen, openSettings };
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@@ -95,7 +98,9 @@ export function KeyboardShortcuts() {
handlersRef.current.router.push("/chat/new");
break;
case "open-settings":
- handlersRef.current.router.push("/settings");
+ // Settings-app wave: the dialog is the surface, the URL is
+ // gone — /settings redirects to the draft shell.
+ handlersRef.current.openSettings();
break;
case "jump-graph":
handlersRef.current.router.push("/");
diff --git a/components/sidebar.tsx b/components/sidebar.tsx
index 89d05f6..6e1702a 100644
--- a/components/sidebar.tsx
+++ b/components/sidebar.tsx
@@ -8,6 +8,7 @@ import { ActivitySection } from "@/components/activity-dock";
import { LhMark } from "@/components/lh-mark";
import { BrailleSpinner } from "@/components/braille-spinner";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
+import { SettingsDialogProvider, useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
@@ -265,6 +266,7 @@ function SessionsSidebar() {
const [query, setQuery] = useState("");
const pathname = usePathname();
const router = useRouter();
+ const { open: openSettings } = useSettingsDialog();
/* New session (owner, 2026-09-13): NO POST — the button opens the
sessionless draft view; the session is created only when the first
@@ -483,18 +485,19 @@ function SessionsSidebar() {
learner opted out (no ambient signal, user decision). */}
- {/* Settings entry (thin settings wave): the same row language
- as New session — quiet, bottom-pinned, one chord away is
- mod+, (the keyboard registry's open-settings action). */}
+ {/* Settings entry (settings-app wave): same row language as
+ New session — quiet, bottom-pinned, opens the centered
+ dialog (also mod+, in the keyboard registry). */}
-
Settings
-
+
@@ -504,13 +507,13 @@ function SessionsSidebar() {
/** The app frame: sidebar inline + content. Rendered once in the root layout. */
export function AppShell({ children }: { children: React.ReactNode }) {
return (
- <>
+ {children}
{/* The single document-level keydown listener (keyboard wave A) —
after the content in the tree, but it's window-level so order only
matters for the events it dispatches, never for DOM priority. */}
- >
+
);
}