Skip to content

Commit 34ddbd7

Browse files
josers18claude
andcommitted
feat(mobile): unblock /analyze + /ask on phones — drawer pattern + chrome swap
Below lg (1024px) the model list and thread list were hidden with no mobile alternative — /analyze even told the user to "select a model from the sidebar" that wasn't in the DOM. The desktop LeftRail also reserved 17% of phone viewport via an always-on pl-16. - New MobileDrawer primitive (left-anchored, focus-trap, Esc, scroll-lock, backdrop tap). Renders nothing at lg+ — desktop hosts content directly. - AnalyzeMobileSidebar / AskMobileSidebar — small client wrappers that trigger the drawer and host the existing ModelList / ThreadList unchanged. ModelList + ThreadList grow an optional onSelect callback so the drawer can close after navigation. - AnalyzeEntry copy no longer references a missing sidebar. - MobileNav rewritten: was a 2-button scroll pill, now a 3-icon section nav (Today / Ask / Analyze) mirroring LeftRail. Active state from usePathname; disabled state when signed out, matching LeftRail. - (banker) layout: LeftRail wrapped in hidden lg:contents; pl-16 → lg:pl-16; MobileNav mounted globally with signedIn so /ask + /analyze get nav too. - HorizonSignedIn no longer mounts MobileNav (was /-only). - Tailwind: slide-in-left keyframe + animation (mirrors slide-in-right). Spec: docs/superpowers/specs/2026-05-12-mobile-responsive-design.md. Phase 0 (drawer everywhere + chrome swap) — targeted sweep follows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d48a9a8 commit 34ddbd7

13 files changed

Lines changed: 603 additions & 41 deletions

app/(banker)/layout.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Suspense } from "react";
22
import { cookies } from "next/headers";
33
import { LeftRail } from "@/components/nav/LeftRail";
4+
import { MobileNav } from "@/components/horizon/mobile/MobileNav";
45

56
export const dynamic = "force-dynamic";
67

@@ -12,6 +13,12 @@ export const dynamic = "force-dynamic";
1213
// ClientDetailSheet / etc.) all resolve serially. With the boundary,
1314
// the rail + empty content region paint in <100ms and the banker can
1415
// click ⌘2 / Ask My Data even while Today is still loading.
16+
//
17+
// Mobile chrome: below lg the desktop rail hides and MobileNav (a
18+
// floating bottom pill mirroring the rail's three sections) takes
19+
// over. The rail's `fixed` positioning means we just gate render —
20+
// the layout's pl-16 also gates to lg+ to reclaim the 64px gutter
21+
// for content on phones.
1522
export default async function BankerLayout({
1623
children,
1724
}: {
@@ -21,10 +28,13 @@ export default async function BankerLayout({
2128

2229
return (
2330
<div className="flex min-h-dvh">
24-
<LeftRail signedIn={signedIn} />
25-
<div className="min-w-0 flex-1 pl-16">
31+
<div className="hidden lg:contents">
32+
<LeftRail signedIn={signedIn} />
33+
</div>
34+
<div className="min-w-0 flex-1 lg:pl-16">
2635
<Suspense fallback={null}>{children}</Suspense>
2736
</div>
37+
<MobileNav signedIn={signedIn} />
2838
</div>
2939
);
3040
}

components/analyze/AnalyzeEntry.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* Entry state for /analyze when no model is selected. Lives in the main
3-
* column of AnalyzeWorkspace; the sidebar (ModelList) carries the actual
4-
* picker. Kept copy-focused — everything useful happens on the left.
3+
* column of AnalyzeWorkspace. On desktop the picker sits in a sticky
4+
* sidebar; on mobile (<lg) it lives behind the "Browse models" trigger
5+
* rendered by AnalyzeMobileSidebar.
56
*/
67
export function AnalyzeEntry() {
78
return (
@@ -13,13 +14,19 @@ export function AnalyzeEntry() {
1314
Pick a model to explore.
1415
</h1>
1516
<p className="mt-3 max-w-xl text-[14px] text-text-muted">
16-
Select a semantic data model from the sidebar. You&rsquo;ll get the
17-
model&rsquo;s profile, named metrics, and an Ask bar that runs
18-
natural-language analysis through Tableau Next&rsquo;s Analytics Agent.
17+
Choose a semantic data model to get its profile, named metrics, and
18+
an Ask bar that runs natural-language analysis through Tableau
19+
Next&rsquo;s Analytics Agent.
1920
</p>
2021
<p className="mt-2 max-w-xl text-[13px] text-text-muted/80">
21-
All 16 models in this org&rsquo;s dataspace are listed. Search by
22-
name, business domain, or description.
22+
<span className="hidden lg:inline">
23+
The full list lives in the sidebar.
24+
</span>
25+
<span className="lg:hidden">
26+
Tap <strong className="font-medium text-text">Browse models</strong>{" "}
27+
above to open the list.
28+
</span>{" "}
29+
Search by name, business domain, or description.
2330
</p>
2431
</section>
2532
);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { Menu } from "lucide-react";
5+
import { MobileDrawer } from "@/components/horizon/mobile/MobileDrawer";
6+
import { ModelList } from "./ModelList";
7+
8+
/**
9+
* Mobile-only model picker for /analyze. Renders a "Browse models" trigger
10+
* button and a left-anchored drawer hosting the same <ModelList /> the
11+
* desktop <aside> uses. Hidden at lg+ — desktop already has the sidebar.
12+
*/
13+
export function AnalyzeMobileSidebar() {
14+
const [open, setOpen] = useState(false);
15+
return (
16+
<div className="lg:hidden">
17+
<button
18+
type="button"
19+
onClick={() => setOpen(true)}
20+
className="mt-4 flex min-h-[44px] items-center gap-2 rounded-lg border border-border-soft bg-surface px-4 py-2 text-[13px] font-medium text-text transition hover:border-accent/50"
21+
>
22+
<Menu size={15} strokeWidth={1.8} aria-hidden />
23+
Browse models
24+
</button>
25+
<MobileDrawer
26+
open={open}
27+
onClose={() => setOpen(false)}
28+
ariaLabel="Browse semantic models"
29+
title="Models"
30+
>
31+
<ModelList onSelect={() => setOpen(false)} />
32+
</MobileDrawer>
33+
</div>
34+
);
35+
}

components/analyze/AnalyzeWorkspace.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { ModelList } from "./ModelList";
2+
import { AnalyzeMobileSidebar } from "./AnalyzeMobileSidebar";
23

34
/**
45
* 2-column shell for /analyze and /analyze/[modelId].
56
*
67
* - ≥1024px : 280px model sidebar + main column
7-
* - <1024px : main only (sidebar deferred to a future polish pass)
8+
* - <1024px : main column only; the model picker lives in a slide-in
9+
* drawer triggered by AnalyzeMobileSidebar.
810
*
911
* Matches the spec's §T2-4 mock — narrower than Ask My Data's 3-column
1012
* because the right-rail surface in Analyze (governance trail) is a
@@ -19,7 +21,10 @@ export function AnalyzeWorkspace({ children }: { children: React.ReactNode }) {
1921
</div>
2022
</aside>
2123

22-
<div className="relative flex min-w-0 flex-col pb-40">{children}</div>
24+
<div className="relative flex min-w-0 flex-col pb-40">
25+
<AnalyzeMobileSidebar />
26+
{children}
27+
</div>
2328
</div>
2429
);
2530
}

components/analyze/ModelList.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type FetchState =
1212
| { kind: "error"; message: string }
1313
| { kind: "unauth" };
1414

15-
export function ModelList() {
15+
export function ModelList({ onSelect }: { onSelect?: () => void } = {}) {
1616
const params = useParams();
1717
const activeId =
1818
typeof params?.modelId === "string" ? params.modelId : null;
@@ -122,6 +122,7 @@ export function ModelList() {
122122
feel instant even if an analyze turn is running. */}
123123
<a
124124
href={`/analyze/${m.id}`}
125+
onClick={() => onSelect?.()}
125126
className={cn(
126127
"flex flex-col gap-0.5 px-4 py-2.5 text-left transition",
127128
active
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { Menu } from "lucide-react";
5+
import { MobileDrawer } from "@/components/horizon/mobile/MobileDrawer";
6+
import { ThreadList } from "./ThreadList";
7+
8+
/**
9+
* Mobile-only thread picker for /ask. Renders a "Threads" trigger button
10+
* and a left-anchored drawer hosting the same <ThreadList /> the desktop
11+
* <aside> uses. Hidden at lg+ — desktop already has the sidebar.
12+
*/
13+
export function AskMobileSidebar() {
14+
const [open, setOpen] = useState(false);
15+
return (
16+
<div className="lg:hidden">
17+
<button
18+
type="button"
19+
onClick={() => setOpen(true)}
20+
className="mt-4 flex min-h-[44px] items-center gap-2 rounded-lg border border-border-soft bg-surface px-4 py-2 text-[13px] font-medium text-text transition hover:border-accent/50"
21+
>
22+
<Menu size={15} strokeWidth={1.8} aria-hidden />
23+
Threads
24+
</button>
25+
<MobileDrawer
26+
open={open}
27+
onClose={() => setOpen(false)}
28+
ariaLabel="Browse Ask My Data threads"
29+
title="Threads"
30+
>
31+
<ThreadList onSelect={() => setOpen(false)} />
32+
</MobileDrawer>
33+
</div>
34+
);
35+
}

components/ask-data/AskWorkspace.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { ThreadList } from "./ThreadList";
22
import { ContextRail } from "./ContextRail";
3+
import { AskMobileSidebar } from "./AskMobileSidebar";
34

45
/**
56
* 3-column workspace shell for /ask and /ask/[threadId] (spec §T1-2).
67
*
78
* Breakpoints:
89
* - ≥1280px (xl) : threads sidebar (260px) + main + context rail (260px)
910
* - 1024–1279px : threads sidebar (260px) + main; context rail hides
10-
* - <1024px : main only; sidebar + right rail both hide (the
11-
* hamburger drawer for the sidebar is deferred to a
12-
* later polish task)
11+
* - <1024px : main column only; thread picker lives in a slide-in
12+
* drawer triggered by AskMobileSidebar. ContextRail
13+
* stays desktop-only — it's a power-user surface.
1314
*
1415
* The sidebar is `sticky` at the top of the main scroll context so it
1516
* scrolls with the workspace but stays pinned while messages accumulate
@@ -28,7 +29,10 @@ export function AskWorkspace({
2829
</div>
2930
</aside>
3031

31-
<div className="relative flex min-w-0 flex-col pb-40">{children}</div>
32+
<div className="relative flex min-w-0 flex-col pb-40">
33+
<AskMobileSidebar />
34+
{children}
35+
</div>
3236

3337
<div className="hidden xl:block">
3438
<div className="sticky top-[96px] h-[calc(100vh-120px)] overflow-y-auto">

components/ask-data/ThreadList.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type FetchState =
1717
| { kind: "unauth" }
1818
| { kind: "unconfigured" };
1919

20-
export function ThreadList() {
20+
export function ThreadList({ onSelect }: { onSelect?: () => void } = {}) {
2121
const router = useRouter();
2222
const params = useParams();
2323
const activeThreadId =
@@ -82,6 +82,7 @@ export function ThreadList() {
8282
if (!res.ok) return;
8383
const data = (await res.json()) as { thread: ThreadLike };
8484
router.push(`/ask/${data.thread.id}`);
85+
onSelect?.();
8586
await load();
8687
} catch {
8788
/* surfaced via sidebar refresh */
@@ -168,6 +169,7 @@ export function ThreadList() {
168169
>
169170
<Link
170171
href={`/ask/${t.id}`}
172+
onClick={() => onSelect?.()}
171173
className={cn(
172174
"min-w-0 flex-1 truncate rounded-md px-2 py-2 text-[13px] transition",
173175
active

components/horizon/HorizonSignedIn.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
import type { ReactNode } from "react";
44
import { DraftsProvider } from "./DraftsContext";
55
import { PullToRefresh } from "./PullToRefresh";
6-
import { MobileNav } from "./mobile/MobileNav";
76

7+
// MobileNav is mounted globally in app/(banker)/layout.tsx so it appears
8+
// on /ask and /analyze too, not just /. Don't re-mount it here.
89
export function HorizonSignedIn({ children }: { children: ReactNode }) {
910
return (
1011
<DraftsProvider>
1112
<PullToRefresh>{children}</PullToRefresh>
12-
<MobileNav />
1313
</DraftsProvider>
1414
);
1515
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
import { X } from "lucide-react";
5+
import { cn } from "@/lib/utils";
6+
7+
type Props = {
8+
open: boolean;
9+
onClose: () => void;
10+
ariaLabel: string;
11+
/** Optional title rendered in the drawer header. */
12+
title?: string;
13+
children: React.ReactNode;
14+
};
15+
16+
/**
17+
* Left-anchored mobile drawer. Renders nothing at >= lg — desktop hosts the
18+
* same content in a sticky <aside> directly. Used by /analyze (ModelList)
19+
* and /ask (ThreadList) so phone bankers can pick a model / thread.
20+
*
21+
* Closes on: backdrop tap, Esc, programmatic onClose. Locks body scroll
22+
* while open and returns focus to the trigger element on close.
23+
*/
24+
export function MobileDrawer({
25+
open,
26+
onClose,
27+
ariaLabel,
28+
title,
29+
children,
30+
}: Props) {
31+
const panelRef = useRef<HTMLDivElement>(null);
32+
const triggerRef = useRef<HTMLElement | null>(null);
33+
34+
// Capture the element that triggered the open so we can restore focus
35+
// when the drawer closes — important for keyboard users.
36+
useEffect(() => {
37+
if (open) {
38+
triggerRef.current = document.activeElement as HTMLElement | null;
39+
// Defer focus to next tick so the slide-in animation has the panel
40+
// mounted before we move focus into it.
41+
requestAnimationFrame(() => {
42+
panelRef.current?.focus();
43+
});
44+
} else if (triggerRef.current) {
45+
triggerRef.current.focus?.();
46+
triggerRef.current = null;
47+
}
48+
}, [open]);
49+
50+
// Esc closes; body scroll lock while open.
51+
useEffect(() => {
52+
if (!open) return;
53+
function onKey(e: KeyboardEvent) {
54+
if (e.key === "Escape") {
55+
e.preventDefault();
56+
onClose();
57+
}
58+
}
59+
const prevOverflow = document.body.style.overflow;
60+
document.body.style.overflow = "hidden";
61+
window.addEventListener("keydown", onKey);
62+
return () => {
63+
document.body.style.overflow = prevOverflow;
64+
window.removeEventListener("keydown", onKey);
65+
};
66+
}, [open, onClose]);
67+
68+
if (!open) return null;
69+
70+
return (
71+
<div
72+
className="fixed inset-0 z-[70] flex items-stretch justify-start bg-black/60 backdrop-blur-[4px] animate-fade-in lg:hidden"
73+
onClick={onClose}
74+
role="presentation"
75+
>
76+
<div
77+
ref={panelRef}
78+
role="dialog"
79+
aria-modal="true"
80+
aria-label={ariaLabel}
81+
tabIndex={-1}
82+
onClick={(e) => e.stopPropagation()}
83+
className={cn(
84+
"relative flex h-full w-[min(86vw,360px)] flex-col border-r border-border-soft bg-surface shadow-[0_28px_60px_-30px_rgba(0,0,0,0.75)] outline-none animate-slide-in-left",
85+
"pl-[env(safe-area-inset-left,0px)] pt-[env(safe-area-inset-top,0px)] pb-[env(safe-area-inset-bottom,0px)]"
86+
)}
87+
>
88+
<div className="flex items-center justify-between gap-3 border-b border-border-soft/60 px-4 py-3">
89+
<span className="text-[11px] font-medium uppercase tracking-[0.2em] text-text-muted">
90+
{title ?? ariaLabel}
91+
</span>
92+
<button
93+
type="button"
94+
onClick={onClose}
95+
aria-label="Close"
96+
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md text-text-muted transition hover:bg-surface2 hover:text-text"
97+
>
98+
<X size={16} />
99+
</button>
100+
</div>
101+
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
102+
</div>
103+
</div>
104+
);
105+
}

0 commit comments

Comments
 (0)