Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added mcpjam-inspector/client/public/tool-vid-march.mp4
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ExternalLink, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { learnMoreContent } from "@/lib/learn-more-content";

interface LearnMoreExpandedPanelProps {
tabId: string | null;
sourceRect: DOMRect | null;
onClose: () => void;
}

const PANEL_WIDTH = 720;
const EASING: [number, number, number, number] = [0.16, 1, 0.3, 1]; // ease-out-expo

export function LearnMoreExpandedPanel({
tabId,
sourceRect,
onClose,
}: LearnMoreExpandedPanelProps) {
const entry = tabId ? learnMoreContent[tabId] : null;
const panelRef = useRef<HTMLDivElement>(null);

// Close on Escape
useEffect(() => {
if (!tabId) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [tabId, onClose]);

// Compute initial transform from sourceRect to final centered position
const getInitialStyle = () => {
if (!sourceRect) {
// No source (first-visit auto-show) — just scale from center
return { opacity: 0, scale: 0.95 };
}

const viewW = window.innerWidth;
const viewH = window.innerHeight;

// Final position: centered
const finalX = (viewW - PANEL_WIDTH) / 2;
const finalY = viewH * 0.1; // 10% from top

// Offset from center to where the hover card was
const deltaX = sourceRect.left - finalX;
const deltaY = sourceRect.top - finalY;
const scaleX = sourceRect.width / PANEL_WIDTH;

return {
opacity: 0.8,
x: deltaX,
y: deltaY,
scale: scaleX,
transformOrigin: "top left",
};
};

return (
<AnimatePresence>
{entry && tabId && (
<>
{/* Overlay */}
<motion.div
key="learn-more-overlay"
className="fixed inset-0 z-50 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: EASING }}
onClick={onClose}
/>

{/* Panel */}
<motion.div
ref={panelRef}
key="learn-more-panel"
className="fixed z-50 bg-background rounded-lg border shadow-lg p-6 overflow-y-auto"
style={{
top: "10vh",
left: "50%",
marginLeft: -(PANEL_WIDTH / 2),
width: PANEL_WIDTH,
maxWidth: "calc(100vw - 2rem)",
maxHeight: "80vh",
}}
initial={getInitialStyle()}
animate={{
opacity: 1,
x: 0,
y: 0,
scale: 1,
transformOrigin: "top left",
}}
exit={{
opacity: 0,
scale: 0.97,
transition: { duration: 0.15, ease: EASING } as any,
}}
transition={{ duration: 0.35, ease: EASING }}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-none"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>

{/* Header */}
<div className="mb-4">
<h2 className="text-lg font-semibold leading-none pb-1">
{entry.title}
</h2>
<p className="text-muted-foreground text-sm">
{entry.description}
</p>
</div>

{/* Video */}
{entry.videoUrl ? (
<div className="aspect-video w-full overflow-hidden rounded-md bg-muted mb-4">
{entry.videoUrl.endsWith(".mp4") ? (
<video
src={entry.videoUrl}
className="h-full w-full"
autoPlay
loop
muted
playsInline
preload="auto"
title={`${entry.title} video`}
/>
) : (
<iframe
src={entry.videoUrl}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={`${entry.title} video`}
/>
)}
</div>
) : (
<div className="aspect-video w-full overflow-hidden rounded-md bg-muted flex items-center justify-center mb-4">
<p className="text-muted-foreground text-sm">
Video coming soon
</p>
</div>
)}

{/* Docs link */}
<div className="flex justify-end">
<Button variant="outline" size="sm" asChild>
<a
href={entry.docsUrl}
target="_blank"
rel="noopener noreferrer"
>
Read the docs
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</a>
</Button>
</div>
</motion.div>
</>
Comment on lines +78 to +170
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expanded panel is visually modal but not programmatically modal.

The panel lacks modal semantics and focus containment/restoration, so keyboard users can still reach background UI while it is open.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mcpjam-inspector/client/src/components/learn-more/LearnMoreExpandedPanel.tsx`
around lines 78 - 170, The panel is visually modal but missing accessibility:
add programmatic modal semantics and focus management to LearnMoreExpandedPanel
by (1) adding role="dialog" and aria-modal="true" to the motion.div (key
"learn-more-panel") and ensuring the header/title is referenced via
aria-labelledby, (2) capture document.activeElement on mount and restore it in
the cleanup that runs when onClose is invoked, (3) set initial focus to the
close button (the button that renders the XIcon) on open (either via autoFocus
or by focusing it using panelRef.current.querySelector), (4) implement a simple
focus trap on the panelRef that intercepts Tab/Shift+Tab to cycle focus within
focusable elements inside the panel, and (5) add an Escape key listener to call
onClose; ensure background content is not reachable (e.g., toggle aria-hidden on
the app root or rely on aria-modal=true) and clean up all listeners on unmount.

)}
</AnimatePresence>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useRef, useState } from "react";
import { Maximize2 } from "lucide-react";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { learnMoreContent } from "@/lib/learn-more-content";

interface LearnMoreHoverCardProps {
tabId: string;
children: React.ReactNode;
onExpand: (tabId: string, sourceRect: DOMRect | null) => void;
}

export function LearnMoreHoverCard({
tabId,
children,
onExpand,
}: LearnMoreHoverCardProps) {
const entry = learnMoreContent[tabId];
const wrapperRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);

if (!entry) return <>{children}</>;

const handleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
const rect = wrapperRef.current?.getBoundingClientRect() ?? null;
// Close hover card instantly before expanding
setOpen(false);
// Small delay to let hover card unmount before panel mounts
requestAnimationFrame(() => {
onExpand(tabId, rect);
});
};

return (
<HoverCard openDelay={400} closeDelay={200} open={open} onOpenChange={setOpen}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent side="right" sideOffset={8} className="w-80">
<div ref={wrapperRef}>
<div className="relative mb-3 overflow-hidden rounded-md bg-muted">
<button
onClick={handleExpand}
className="absolute top-1.5 right-1.5 z-10 p-1 rounded-sm bg-black/40 text-white hover:bg-black/60 transition-colors"
aria-label="Expand"
>
<Maximize2 className="h-3 w-3" />
</button>
{entry.videoUrl?.endsWith(".mp4") ? (
<video
src={entry.videoUrl}
className="w-full h-auto"
autoPlay
loop
muted
playsInline
preload="auto"
/>
) : entry.videoThumbnail ? (
<img
src={entry.videoThumbnail}
alt={`${entry.title} preview`}
className="w-full h-auto"
/>
) : (
<div className="aspect-video flex items-center justify-center">
<p className="text-muted-foreground text-xs">Preview</p>
</div>
)}
</div>

<p className="text-sm text-muted-foreground">
{entry.description}
</p>
</div>
</HoverCardContent>
</HoverCard>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ExternalLink } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { learnMoreContent } from "@/lib/learn-more-content";

interface LearnMoreModalProps {
tabId: string | null;
onClose: () => void;
}

export function LearnMoreModal({ tabId, onClose }: LearnMoreModalProps) {
const entry = tabId ? learnMoreContent[tabId] : null;

if (!entry) return null;

return (
<Dialog open={!!tabId} onOpenChange={() => onClose()}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{entry.title}</DialogTitle>
<DialogDescription>{entry.description}</DialogDescription>
</DialogHeader>

{entry.videoUrl && (
<div className="aspect-video w-full overflow-hidden rounded-md bg-muted">
{entry.videoUrl.endsWith(".mp4") ? (
<video
src={entry.videoUrl}
className="h-full w-full"
autoPlay
loop
muted
playsInline
preload="auto"
title={`${entry.title} video`}
/>
) : (
<iframe
src={entry.videoUrl}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={`${entry.title} video`}
/>
)}
</div>
)}

{!entry.videoUrl && (
<div className="aspect-video w-full overflow-hidden rounded-md bg-muted flex items-center justify-center">
<p className="text-muted-foreground text-sm">
Video coming soon
</p>
</div>
)}

<div className="flex justify-end">
<Button variant="outline" size="sm" asChild>
<a
href={entry.docsUrl}
target="_blank"
rel="noopener noreferrer"
>
Read the docs
<ExternalLink className="ml-1.5 h-3.5 w-3.5" />
</a>
</Button>
</div>
</DialogContent>
</Dialog>
);
}
19 changes: 19 additions & 0 deletions mcpjam-inspector/client/src/components/mcp-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import {
normalizeHostedHashTab,
} from "@/lib/hosted-tab-policy";
import { HOSTED_LOCAL_ONLY_TOOLTIP } from "@/lib/hosted-ui";
import { useLearnMore } from "@/hooks/use-learn-more";
import { LearnMoreExpandedPanel } from "@/components/learn-more/LearnMoreExpandedPanel";
import type { BillingFeatureName } from "@/hooks/useOrganizationBilling";
import type { ServerWithName } from "@/hooks/use-app-state";
import type { Workspace } from "@/state/app-types";
Expand Down Expand Up @@ -341,6 +343,7 @@ export function MCPSidebar({
const [hasVisitedAppBuilder, setHasVisitedAppBuilder] = useState(() => {
return localStorage.getItem(APP_BUILDER_VISITED_KEY) === "true";
});
const learnMore = useLearnMore();

// Get list of connected server names
const connectedServerNames = useMemo(() => {
Expand Down Expand Up @@ -410,6 +413,11 @@ export function MCPSidebar({
if (section === "skills") {
posthog.capture("skills_tab_opened");
}
// Auto-show learn more modal on first visit to a tab
if (learnMore.shouldAutoShowModal(section)) {
learnMore.openExpandedModal(section);
learnMore.markTabVisited(section);
}
onNavigate(section);
} else {
window.open(url, "_blank");
Expand Down Expand Up @@ -455,6 +463,7 @@ export function MCPSidebar({
);

return (
<>
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<button
Expand Down Expand Up @@ -502,6 +511,10 @@ export function MCPSidebar({
appBuilderBubble={
section.id === "mcp-apps" ? appBuilderBubble : null
}
learnMore={{
hasVisited: learnMore.hasVisitedTab,
onExpand: learnMore.openExpandedModal,
}}
/>
{/* Add subtle divider between sections (except after the last section) */}
{sectionIndex < visibleNavigationSections.length - 1 && (
Expand All @@ -514,5 +527,11 @@ export function MCPSidebar({
<SidebarUser activeOrganizationId={activeOrganizationId} />
</SidebarFooter>
</Sidebar>
<LearnMoreExpandedPanel
tabId={learnMore.expandedTabId}
sourceRect={learnMore.sourceRect}
onClose={learnMore.closeExpandedModal}
/>
</>
);
}
Loading
Loading