feat(dashboard): add mobile layout and responsive fixes

Bumps to 2.6.0.

- MobileLayout activates via useIsMobile at <768px with bottom-tab
  navigation (Graph/Info/Files); panes stay mounted (visibility
  toggle) to preserve ReactFlow dimensions and FileExplorer state.
- MobileDrawer holds persona, view mode, diff, node-type filters,
  layers, and tool buttons (Filter/Export/Path/Theme/Help).
- Selecting a node auto-pivots to Info; CodeViewer is always
  fullscreen on mobile; SearchBar collapses to a 🔍 toggle.
- Homepage Hero/Footer/Install responsive: drop nowrap on title and
  tagline, stack title spans for editorial wrap, full-width CTAs at
  <480px, narrow-width spacing refinements.
- Desktop dashboard: sidebar telescopes 260/300/360px, header gaps
  tighten, Path button label collapses to icon at narrow widths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-05-05 16:25:56 +08:00
Unverified
parent 6e0d1c11ea
commit 880e223bb4
14 changed files with 692 additions and 21 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.5.1",
"version": "2.6.0",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.5.1",
"version": "2.6.0",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "understand-anything",
"displayName": "Understand Anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.5.1",
"version": "2.6.0",
"author": {
"name": "Lum1104"
},
+14
View File
@@ -28,6 +28,14 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
margin: 0 auto;
}
.footer-links {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem 0.25rem;
}
.footer-logo {
font-family: var(--font-heading);
font-size: 1.15rem;
@@ -60,4 +68,10 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
font-size: 0.8rem;
color: var(--text-muted);
}
@media (max-width: 480px) {
.footer { padding: 3rem 1.25rem; }
.footer-sep { display: none; }
.footer-links { gap: 0.25rem 1rem; }
}
</style>
+46 -4
View File
@@ -122,12 +122,13 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
/* Headline */
.hero-title {
font-family: var(--font-heading);
font-size: clamp(3.5rem, 9vw, 7rem);
font-size: clamp(2.75rem, 9vw, 7rem);
color: #e8e2d8;
line-height: 1.05;
letter-spacing: -0.025em;
margin-bottom: 2rem;
white-space: nowrap;
max-width: 100%;
}
.hero-grad {
@@ -141,7 +142,7 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
/* Tagline */
.hero-tagline {
font-size: clamp(1.1rem, 2vw, 1.4rem);
font-size: clamp(1rem, 2vw, 1.4rem);
color: #8a8578;
line-height: 1.7;
max-width: 900px;
@@ -329,15 +330,56 @@ const githubUrl = 'https://github.com/Lum1104/Understand-Anything';
.anim-6 { animation-delay: 1.15s; }
/* Responsive */
@media (max-width: 900px) {
.hero-tagline {
white-space: normal;
max-width: 36ch;
margin-left: auto;
margin-right: auto;
}
}
@media (max-width: 768px) {
.hero-pillars { gap: 0.5rem; }
.hero { padding: 5rem 1.5rem 3rem; min-height: auto; }
.hero-content { gap: 0; }
.hero-pillars { gap: 0.5rem; margin-bottom: 2.25rem; }
.pillar { font-size: 0.8rem; }
.hero-badge {
font-size: 0.72rem;
padding: 0.35rem 1rem;
margin-bottom: 1.75rem;
}
.hero-title { margin-bottom: 1.5rem; }
}
@media (max-width: 640px) {
.hero-title {
white-space: normal;
line-height: 1.02;
letter-spacing: -0.03em;
}
/* Stack the two words on dedicated lines for editorial drama */
.hero-title > span { display: block; }
}
@media (max-width: 480px) {
.hero-actions { flex-direction: column; gap: 1rem; }
.hero { padding: 4rem 1.25rem 2.25rem; }
.hero-actions {
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 320px;
}
.hero-cta,
.hero-demo {
width: 100%;
text-align: center;
padding: 0.85rem 1.5rem;
}
.hero-pillars { flex-direction: column; gap: 0.4rem; }
.pillar-dot { display: none; }
.hero-subscribe { margin-top: 1.5rem; }
.hero-subscribe-toggle { font-size: 0.85rem; }
.hero-subscribe-frame iframe { height: 130px; }
}
</style>
+10
View File
@@ -131,4 +131,14 @@
.install-note strong {
color: var(--text);
}
@media (max-width: 480px) {
.install { padding: 4.5rem 1.25rem; }
.install-title { margin-bottom: 1.75rem; }
pre { padding: 1rem 1.1rem; }
code { font-size: 0.82rem; line-height: 1.7; }
.install-code-header { padding: 0.65rem 0.85rem; }
.install-code-label { font-size: 0.72rem; }
.install-note { font-size: 0.82rem; line-height: 1.55; }
}
</style>
@@ -1,7 +1,7 @@
{
"name": "understand-anything",
"description": "AI-powered codebase understanding — analyze, visualize, and explain any project",
"version": "2.5.1",
"version": "2.6.0",
"author": {
"name": "Lum1104"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@understand-anything/skill",
"version": "2.5.1",
"version": "2.6.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -16,6 +16,8 @@ import ProjectOverview from "./components/ProjectOverview";
import FileExplorer from "./components/FileExplorer";
import WarningBanner from "./components/WarningBanner";
import TokenGate from "./components/TokenGate";
import MobileLayout from "./components/MobileLayout";
import { useIsMobile } from "./hooks/useIsMobile";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts";
import { ThemeProvider } from "./themes/index.ts";
@@ -118,6 +120,7 @@ function Dashboard({ accessToken }: { accessToken: string }) {
const domainGraph = useDashboardStore((s) => s.domainGraph);
const setDomainGraph = useDashboardStore((s) => s.setDomainGraph);
const layoutIssues = useDashboardStore((s) => s.layoutIssues);
const isMobile = useIsMobile();
// Schema issues + ELK layout issues share the WarningBanner — graph-load
// problems and dashboard rendering problems are equally surfaced.
const allIssues = useMemo(
@@ -376,17 +379,32 @@ function Dashboard({ accessToken }: { accessToken: string }) {
</div>
);
if (isMobile) {
return (
<ThemeProvider metaTheme={metaTheme}>
<MobileLayout
accessToken={accessToken}
showKeyboardHelp={showKeyboardHelp}
setShowKeyboardHelp={setShowKeyboardHelp}
loadError={loadError}
allIssues={allIssues}
shortcuts={shortcuts}
/>
</ThemeProvider>
);
}
return (
<ThemeProvider metaTheme={metaTheme}>
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
{/* Header */}
<header className="flex items-center px-5 py-3 bg-surface border-b border-border-subtle shrink-0 gap-4">
<header className="flex items-center px-3 sm:px-5 py-3 bg-surface border-b border-border-subtle shrink-0 gap-2 sm:gap-4">
{/* Left — fixed */}
<div className="flex items-center gap-5 shrink-0">
<h1 className="font-serif text-lg text-text-primary tracking-wide">
<div className="flex items-center gap-3 sm:gap-5 shrink-0 min-w-0">
<h1 className="font-serif text-base sm:text-lg text-text-primary tracking-wide truncate max-w-[160px] sm:max-w-[220px] lg:max-w-none">
{graph?.project.name ?? "Understand Anything"}
</h1>
<div className="w-px h-5 bg-border-subtle" />
<div className="w-px h-5 bg-border-subtle hidden sm:block" />
<PersonaSelector />
{graph && !isKnowledgeGraph && domainGraph && (
<>
@@ -463,12 +481,12 @@ function Dashboard({ accessToken }: { accessToken: string }) {
</div>
{/* Right — fixed actions */}
<div className="flex items-center gap-4 shrink-0">
<div className="flex items-center gap-2 sm:gap-4 shrink-0">
<FilterPanel />
<ExportMenu />
<button
onClick={togglePathFinder}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
className="flex items-center gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
title="Find path between nodes (P)"
>
<svg
@@ -484,7 +502,7 @@ function Dashboard({ accessToken }: { accessToken: string }) {
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
Path
<span className="hidden md:inline">Path</span>
</button>
<ThemePicker />
<button
@@ -540,8 +558,8 @@ function Dashboard({ accessToken }: { accessToken: string }) {
</div>
</div>
{/* Right sidebar */}
<aside className="w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-auto">
{/* Right sidebar — telescopes at narrower widths */}
<aside className="w-[260px] md:w-[300px] lg:w-[360px] shrink-0 bg-surface border-l border-border-subtle overflow-auto">
{sidebarContent}
</aside>
@@ -0,0 +1,73 @@
import type { ReactNode } from "react";
export type MobileTab = "graph" | "info" | "files";
interface Props {
activeTab: MobileTab;
onTabChange: (tab: MobileTab) => void;
}
const tabs: { id: MobileTab; label: string; icon: ReactNode }[] = [
{
id: "graph",
label: "Graph",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
<circle cx="6" cy="7" r="2" />
<circle cx="18" cy="7" r="2" />
<circle cx="12" cy="17" r="2" />
<path strokeLinecap="round" d="M7.6 8.5L11 15.5M16.4 8.5L13 15.5M8 7h8" />
</svg>
),
},
{
id: "info",
label: "Info",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
<circle cx="12" cy="12" r="9" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 11v5M12 8h.01" />
</svg>
),
},
{
id: "files",
label: "Files",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 6.5A1.5 1.5 0 0 1 5.5 5h3.382a1.5 1.5 0 0 1 1.342.83l.671 1.34A1.5 1.5 0 0 0 12.236 8H18.5A1.5 1.5 0 0 1 20 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z"
/>
</svg>
),
},
];
export default function MobileBottomNav({ activeTab, onTabChange }: Props) {
return (
<nav className="flex shrink-0 bg-surface border-t border-border-subtle">
{tabs.map((tab) => {
const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={`relative flex-1 flex flex-col items-center justify-center gap-1 py-2.5 text-[10px] font-semibold uppercase tracking-[0.14em] transition-colors ${
active ? "text-accent" : "text-text-muted hover:text-text-secondary"
}`}
aria-current={active ? "page" : undefined}
>
<span className="w-5 h-5">{tab.icon}</span>
{tab.label}
{active && (
<span className="absolute top-0 left-1/2 -translate-x-1/2 w-8 h-px bg-accent" />
)}
</button>
);
})}
</nav>
);
}
@@ -0,0 +1,262 @@
import { useEffect } from "react";
import { useDashboardStore } from "../store";
import PersonaSelector from "./PersonaSelector";
import DiffToggle from "./DiffToggle";
import LayerLegend from "./LayerLegend";
import FilterPanel from "./FilterPanel";
import ExportMenu from "./ExportMenu";
import { ThemePicker } from "./ThemePicker";
interface Props {
open: boolean;
onClose: () => void;
onTogglePathFinder: () => void;
onShowKeyboardHelp: () => void;
}
interface NodeTypeFilterDef {
key: "code" | "config" | "docs" | "infra" | "data" | "domain" | "knowledge";
label: string;
color: string;
}
const STRUCTURAL_FILTERS: NodeTypeFilterDef[] = [
{ key: "code", label: "Code", color: "var(--color-node-file)" },
{ key: "config", label: "Config", color: "var(--color-node-config)" },
{ key: "docs", label: "Docs", color: "var(--color-node-document)" },
{ key: "infra", label: "Infra", color: "var(--color-node-service)" },
{ key: "data", label: "Data", color: "var(--color-node-table)" },
{ key: "domain", label: "Domain", color: "var(--color-node-concept)" },
{ key: "knowledge", label: "Knowledge", color: "var(--color-node-article)" },
];
const KNOWLEDGE_FILTERS: NodeTypeFilterDef[] = [
{ key: "knowledge", label: "All", color: "var(--color-node-article)" },
];
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-[10px] font-semibold uppercase tracking-[0.18em] text-text-muted mb-3">
{children}
</h3>
);
}
export default function MobileDrawer({
open,
onClose,
onTogglePathFinder,
onShowKeyboardHelp,
}: Props) {
const graph = useDashboardStore((s) => s.graph);
const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const viewMode = useDashboardStore((s) => s.viewMode);
const setViewMode = useDashboardStore((s) => s.setViewMode);
const nodeTypeFilters = useDashboardStore((s) => s.nodeTypeFilters);
const toggleNodeTypeFilter = useDashboardStore((s) => s.toggleNodeTypeFilter);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, onClose]);
// Lock body scroll while open so the page behind doesn't drift
useEffect(() => {
if (!open) return;
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original;
};
}, [open]);
const filterDefs = isKnowledgeGraph ? KNOWLEDGE_FILTERS : STRUCTURAL_FILTERS;
const showViewToggle = Boolean(graph && !isKnowledgeGraph && domainGraph);
return (
<div
className={`fixed inset-0 z-40 ${open ? "pointer-events-auto" : "pointer-events-none"}`}
aria-hidden={!open}
>
{/* Backdrop */}
<button
type="button"
aria-label="Close menu"
onClick={onClose}
className={`absolute inset-0 bg-black/65 backdrop-blur-sm transition-opacity duration-300 ${
open ? "opacity-100" : "opacity-0"
}`}
/>
{/* Panel */}
<aside
className={`absolute left-0 top-0 bottom-0 w-[86%] max-w-[360px] bg-surface border-r border-border-subtle flex flex-col transition-transform duration-300 ease-out ${
open ? "translate-x-0" : "-translate-x-full"
}`}
role="dialog"
aria-label="Settings"
>
{/* Drawer header */}
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle shrink-0">
<div>
<span className="text-[10px] font-semibold uppercase tracking-[0.2em] text-accent">
Controls
</span>
<h2 className="font-serif text-lg text-text-primary mt-0.5 leading-none">
{graph?.project.name ?? "Dashboard"}
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close menu"
className="w-9 h-9 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-elevated transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M6 18L18 6" />
</svg>
</button>
</header>
{/* Body */}
<div className="flex-1 overflow-auto px-5 py-5 space-y-7">
<section>
<SectionLabel>Role</SectionLabel>
<PersonaSelector />
</section>
{showViewToggle && (
<section>
<SectionLabel>View</SectionLabel>
<div className="inline-flex items-center bg-elevated rounded-lg p-0.5">
<button
type="button"
onClick={() => setViewMode("domain")}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
viewMode === "domain"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
Domain
</button>
<button
type="button"
onClick={() => setViewMode("structural")}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
viewMode === "structural"
? "bg-accent/20 text-accent"
: "text-text-muted hover:text-text-secondary"
}`}
>
Structural
</button>
</div>
</section>
)}
<section>
<SectionLabel>Diff overlay</SectionLabel>
<DiffToggle />
</section>
<section>
<SectionLabel>Node types</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{filterDefs.map((cat) => {
const active = nodeTypeFilters[cat.key] !== false;
return (
<button
key={cat.key}
type="button"
onClick={() => toggleNodeTypeFilter(cat.key)}
className={`text-[10px] font-semibold uppercase tracking-wider px-2 py-1 rounded border transition-colors flex items-center gap-1.5 whitespace-nowrap ${
active
? "border-border-medium bg-elevated text-text-secondary"
: "border-transparent bg-transparent text-text-muted/40 line-through"
}`}
>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor: cat.color,
opacity: active ? 1 : 0.3,
}}
/>
{cat.label}
</button>
);
})}
</div>
</section>
{graph && (graph.layers?.length ?? 0) > 0 && (
<section>
<SectionLabel>Layers</SectionLabel>
<div className="-mx-1">
<LayerLegend />
</div>
</section>
)}
<section>
<SectionLabel>Tools</SectionLabel>
<div className="flex flex-wrap items-center gap-2">
<FilterPanel />
<ExportMenu />
<button
type="button"
onClick={() => {
onTogglePathFinder();
onClose();
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
Path
</button>
<ThemePicker />
<button
type="button"
onClick={() => {
onShowKeyboardHelp();
onClose();
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-elevated text-text-secondary hover:text-text-primary transition-colors"
aria-label="Keyboard shortcuts"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Help
</button>
</div>
</section>
</div>
</aside>
</div>
);
}
@@ -0,0 +1,231 @@
import { lazy, Suspense, useEffect, useState } from "react";
import type { GraphIssue } from "@understand-anything/core/schema";
import { useDashboardStore } from "../store";
import GraphView from "./GraphView";
import DomainGraphView from "./DomainGraphView";
import KnowledgeGraphView from "./KnowledgeGraphView";
import SearchBar from "./SearchBar";
import NodeInfo from "./NodeInfo";
import ProjectOverview from "./ProjectOverview";
import FileExplorer from "./FileExplorer";
import WarningBanner from "./WarningBanner";
import MobileBottomNav from "./MobileBottomNav";
import type { MobileTab } from "./MobileBottomNav";
import MobileDrawer from "./MobileDrawer";
const CodeViewer = lazy(() => import("./CodeViewer"));
const LearnPanel = lazy(() => import("./LearnPanel"));
const PathFinderModal = lazy(() => import("./PathFinderModal"));
const KeyboardShortcutsHelp = lazy(() => import("./KeyboardShortcutsHelp"));
interface Props {
accessToken: string;
showKeyboardHelp: boolean;
setShowKeyboardHelp: (value: boolean) => void;
loadError: string | null;
allIssues: GraphIssue[];
shortcuts: import("../hooks/useKeyboardShortcuts").KeyboardShortcut[];
}
export default function MobileLayout({
accessToken,
showKeyboardHelp,
setShowKeyboardHelp,
loadError,
allIssues,
shortcuts,
}: Props) {
const graph = useDashboardStore((s) => s.graph);
const selectedNodeId = useDashboardStore((s) => s.selectedNodeId);
const tourActive = useDashboardStore((s) => s.tourActive);
const persona = useDashboardStore((s) => s.persona);
const viewMode = useDashboardStore((s) => s.viewMode);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const codeViewerOpen = useDashboardStore((s) => s.codeViewerOpen);
const closeCodeViewer = useDashboardStore((s) => s.closeCodeViewer);
const pathFinderOpen = useDashboardStore((s) => s.pathFinderOpen);
const togglePathFinder = useDashboardStore((s) => s.togglePathFinder);
const [activeTab, setActiveTab] = useState<MobileTab>("graph");
const [drawerOpen, setDrawerOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
// Auto-pivot to Info when a node is selected — keeps feedback visible
// on a small screen where graph and sidebar can't coexist
useEffect(() => {
if (selectedNodeId) setActiveTab("info");
}, [selectedNodeId]);
// When a code viewer opens (e.g. from the Files tab) keep focus there
useEffect(() => {
if (codeViewerOpen) setSearchOpen(false);
}, [codeViewerOpen]);
const isLearnMode = tourActive || persona === "junior";
const infoContent = (
<>
{selectedNodeId && <NodeInfo />}
{isLearnMode && (
<Suspense fallback={null}>
<LearnPanel />
</Suspense>
)}
{!selectedNodeId && !isLearnMode && <ProjectOverview />}
</>
);
return (
<div className="h-screen w-screen flex flex-col bg-root text-text-primary noise-overlay">
{/* Top bar */}
<header className="flex items-center gap-2 px-3 h-12 shrink-0 bg-surface border-b border-border-subtle">
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-lg text-text-secondary hover:text-text-primary hover:bg-elevated transition-colors -ml-1"
aria-label="Open menu"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<h1 className="font-serif text-base flex-1 min-w-0 truncate text-center text-text-primary tracking-wide">
{graph?.project.name ?? "Understand Anything"}
</h1>
<button
type="button"
onClick={() => setSearchOpen((prev) => !prev)}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-colors -mr-1 ${
searchOpen
? "text-accent bg-accent/15"
: "text-text-secondary hover:text-text-primary hover:bg-elevated"
}`}
aria-label={searchOpen ? "Hide search" : "Show search"}
aria-pressed={searchOpen}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</button>
</header>
{/* Search (collapsible) */}
{searchOpen && <SearchBar />}
{/* Validation warnings */}
{allIssues.length > 0 && !loadError && <WarningBanner issues={allIssues} />}
{/* Load error */}
{loadError && (
<div className="px-4 py-3 bg-red-900/30 border-b border-red-700 text-red-200 text-sm">
{loadError}
</div>
)}
{/* Tabbed content — all panes stay mounted to preserve layout/state.
Inactive panes are kept in the layout (not display:none) so that
ReactFlow keeps real dimensions and pinch/pan don't collapse on
tab switch. */}
<div className="flex-1 min-h-0 relative">
<div
className={`absolute inset-0 ${
activeTab === "graph" ? "" : "invisible pointer-events-none"
}`}
aria-hidden={activeTab !== "graph"}
>
{viewMode === "knowledge" ? (
<KnowledgeGraphView />
) : viewMode === "domain" && domainGraph ? (
<DomainGraphView />
) : (
<GraphView />
)}
</div>
<div
className={`absolute inset-0 overflow-auto bg-surface ${
activeTab === "info" ? "" : "invisible pointer-events-none"
}`}
aria-hidden={activeTab !== "info"}
>
{infoContent}
</div>
<div
className={`absolute inset-0 overflow-auto bg-surface ${
activeTab === "files" ? "" : "invisible pointer-events-none"
}`}
aria-hidden={activeTab !== "files"}
>
<FileExplorer />
</div>
</div>
{/* Bottom tab nav */}
<MobileBottomNav activeTab={activeTab} onTabChange={setActiveTab} />
{/* Drawer */}
<MobileDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onTogglePathFinder={togglePathFinder}
onShowKeyboardHelp={() => setShowKeyboardHelp(true)}
/>
{/* Code viewer — always fullscreen on mobile */}
{codeViewerOpen && (
<div
className="fixed inset-0 z-50 flex bg-black/70 backdrop-blur-sm p-2 sm:p-4"
onMouseDown={closeCodeViewer}
>
<div
className="flex-1 rounded-lg border border-border-medium bg-surface shadow-2xl overflow-hidden"
onMouseDown={(event) => event.stopPropagation()}
>
<Suspense fallback={null}>
<CodeViewer
accessToken={accessToken}
presentation="modal"
onClose={closeCodeViewer}
/>
</Suspense>
</div>
</div>
)}
{/* Keyboard help (mobile reads it as a quick reference too) */}
{showKeyboardHelp && (
<Suspense fallback={null}>
<KeyboardShortcutsHelp
shortcuts={shortcuts}
onClose={() => setShowKeyboardHelp(false)}
/>
</Suspense>
)}
{/* Path finder */}
{pathFinderOpen && (
<Suspense fallback={null}>
<PathFinderModal isOpen={pathFinderOpen} onClose={togglePathFinder} />
</Suspense>
)}
</div>
);
}
@@ -84,7 +84,7 @@ export default function SearchBar() {
return (
<div ref={containerRef} className="relative z-30">
<div className="flex items-center gap-2 px-4 py-2 bg-surface border-b border-border-subtle">
<div className="flex items-center gap-2 px-3 sm:px-4 py-2 bg-surface border-b border-border-subtle">
<svg
className="w-4 h-4 text-text-muted shrink-0"
fill="none"
@@ -105,7 +105,7 @@ export default function SearchBar() {
onChange={handleInputChange}
onFocus={() => setDropdownOpen(true)}
placeholder="Search nodes by name, summary, or tags..."
className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted"
className="flex-1 min-w-0 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted"
/>
<div className="flex items-center gap-1 bg-elevated rounded-lg p-0.5 shrink-0">
<button
@@ -130,7 +130,7 @@ export default function SearchBar() {
</button>
</div>
{searchQuery.trim() && (
<span className="text-xs text-text-muted shrink-0">
<span className="hidden sm:inline text-xs text-text-muted shrink-0">
{searchResults.length} result{searchResults.length !== 1 ? "s" : ""}{" "}
<span className="text-text-muted">({searchMode})</span>
</span>
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
const DEFAULT_BREAKPOINT = 768;
export function useIsMobile(breakpoint: number = DEFAULT_BREAKPOINT): boolean {
const query = `(max-width: ${breakpoint - 1}px)`;
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches);
setIsMobile(mql.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return isMobile;
}