Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ExternalLink, XIcon, Play } from "lucide-react";
import { learnMoreContent } from "@/lib/learn-more-content";

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

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

function VideoThumbnail({
entry,
}: {
entry: { title: string; videoUrl: string; videoThumbnail?: string };
}) {
const [playing, setPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);

const isMP4 = entry.videoUrl?.endsWith(".mp4");
const isYouTube = entry.videoUrl?.includes("youtube.com/embed/");
const youtubeId = isYouTube
? entry.videoUrl.split("/embed/")[1]?.split("?")[0]
: null;
const hasVideo = !!entry.videoUrl;

if (!hasVideo) {
return (
<div className="aspect-video w-full bg-muted flex items-center justify-center rounded-lg">
<p className="text-muted-foreground text-sm">Video coming soon</p>
</div>
);
}

// Playing state: show the actual video/iframe
if (playing) {
return (
<div className="aspect-video w-full overflow-hidden rounded-lg bg-black">
{isMP4 ? (
<video
ref={videoRef}
src={entry.videoUrl}
className="h-full w-full"
autoPlay
loop
muted
playsInline
preload="auto"
controls
title={`${entry.title} video`}
/>
) : (
<iframe
src={`${entry.videoUrl}${entry.videoUrl.includes("?") ? "&" : "?"}autoplay=1`}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={`${entry.title} video`}
/>
)}
</div>
);
}

// Thumbnail state (Notion-style): dark overlay + title + play button + Watch on YouTube
const thumbnailSrc = entry.videoThumbnail
? entry.videoThumbnail
: isMP4
? undefined
: isYouTube && youtubeId
? `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg`
: undefined;

return (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-neutral-900 group">
{/* Background image / video poster */}
{thumbnailSrc ? (
<img
src={thumbnailSrc}
alt={`${entry.title} preview`}
className="h-full w-full object-cover"
/>
) : isMP4 ? (
<video
src={entry.videoUrl}
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
/>
) : null}

{/* Dark gradient overlay */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-black/30 group-hover:from-black/75 group-hover:via-black/45 group-hover:to-black/35 transition-colors" />

{/* Title overlay (top-left) */}
<div className="pointer-events-none absolute top-4 left-5">
<p className="text-white text-lg font-semibold drop-shadow-md">
{entry.title}
</p>
<p className="text-white/70 text-sm">MCPJam Inspector</p>
</div>

{/* Centered play button */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="rounded-full bg-white/90 group-hover:bg-white p-4 shadow-lg transition-colors">
<Play className="h-6 w-6 text-black fill-black" />
</div>
</div>

<button
type="button"
onClick={() => setPlaying(true)}
className="absolute inset-0 z-10 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={`Play ${entry.title} video`}
/>

{/* Watch on YouTube badge (bottom-right) — only for YouTube videos */}
{isYouTube && youtubeId && (
<a
href={`https://www.youtube.com/watch?v=${youtubeId}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="absolute bottom-3 right-4 z-20 flex items-center gap-1.5 bg-black/70 hover:bg-black/90 text-white text-xs font-medium px-3 py-1.5 rounded transition-colors"
>
Watch on <span className="font-bold">YouTube</span>
</a>
)}
</div>
);
}

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]);
Comment on lines +137 to +153
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Accessibility: Panel lacks focus management.

Per WCAG modal dialog patterns, this expanded panel should:

  1. Move focus into the panel upon opening
  2. Trap focus within while open
  3. Return focus to the trigger element on close

Currently, only Escape-to-close is implemented. Consider leveraging a focus-trap library or the <Dialog> primitive (as used in LearnMoreModal) to handle these automatically.

🔧 Minimal focus-on-open fix
   useEffect(() => {
     if (!tabId) return;
+    // Move focus into panel on open
+    panelRef.current?.focus();
     const handleKey = (e: KeyboardEvent) => {
       if (e.key === "Escape") onClose();
     };
     document.addEventListener("keydown", handleKey);
     return () => document.removeEventListener("keydown", handleKey);
   }, [tabId, onClose]);

Add tabIndex={-1} to the panel's motion.div to make it focusable. For full compliance, integrate a focus-trap solution.

🤖 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 131 - 147, The expanded panel (LearnMoreExpandedPanel) lacks focus
management: when tabId becomes truthy move focus into the panel, trap focus
while open, and restore focus to the trigger on close. In the effect that
watches [tabId, onClose] use panelRef to save document.activeElement before
opening, set panelRef.current.focus() (ensure the rendered motion.div has
tabIndex={-1}), and on cleanup restore focus to the saved element; for full
accessibility replace this manual handling with a focus-trap (e.g.,
focus-trap-react) or the existing Dialog primitive used elsewhere to enforce
focus trapping and return-focus behavior. Ensure handlers (panelRef, onClose)
and the cleanup logic are updated in LearnMoreExpandedPanel to implement these
steps.


// 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",
};
Comment on lines +155 to +180
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The centering math breaks once maxWidth clamps the panel.

marginLeft: -(PANEL_WIDTH / 2) and the finalX/scale calculations still assume a 900px panel, but maxWidth can render it much narrower. On smaller viewports the panel shifts off-screen to the left, and the open animation targets the wrong destination. Compute the final width from the constrained viewport size, or center via a wrapper/flex container instead of fixed negative margins.

Also applies to: 203-209

🤖 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 155 - 180, The centering math in getInitialStyle assumes
PANEL_WIDTH always equals the rendered panel width, causing wrong finalX/scale
when maxWidth clamps the panel; update getInitialStyle (and the similar logic
around lines 203-209) to compute the actual final width used for centering (e.g.
finalWidth = Math.min(PANEL_WIDTH, window.innerWidth - horizontalPadding) or
read the computed layout width) and use finalWidth instead of PANEL_WIDTH when
calculating finalX and scaleX, or alternatively change the animation target to a
centered wrapper/flex container so the transform math targets the wrapper's
center rather than a fixed 900px value.

};

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 overflow-y-auto overflow-x-hidden"
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-3 right-3 z-10 rounded-full bg-background/80 backdrop-blur-sm p-1.5 opacity-70 transition-opacity hover:opacity-100 focus:outline-none"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>

{/* Title + docs link */}
<div className="px-10 pt-8 pb-2 flex items-start justify-between gap-4">
<h2 className="text-3xl font-bold leading-tight">
{entry.title}
</h2>
<a
href={entry.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 mt-1 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Docs
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
Comment on lines +199 to +249
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Expose the expanded panel as a dialog.

This is modal UI, but assistive tech only sees a generic div. Add role="dialog", aria-modal="true", and connect the heading with aria-labelledby so the panel is announced as an active dialog instead of ordinary page content.

Minimal fix
           <motion.div
             ref={panelRef}
             key="learn-more-panel"
+            role="dialog"
+            aria-modal="true"
+            aria-labelledby="learn-more-title"
             className="fixed z-50 bg-background rounded-lg border shadow-lg overflow-y-auto overflow-x-hidden"
             style={{
               top: "10vh",
@@
-              <h2 className="text-3xl font-bold leading-tight">
+              <h2 id="learn-more-title" className="text-3xl font-bold leading-tight">
                 {entry.title}
               </h2>
🤖 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 199 - 249, The panel's root motion.div (key "learn-more-panel", ref
panelRef) should be exposed as a dialog: add role="dialog" and aria-modal="true"
to that element and set aria-labelledby to the id of the title; give the h2 that
renders {entry.title} a stable id (e.g. "learn-more-title" or an id derived from
entry.id) and reference that id from aria-labelledby so assistive tech announces
the panel as a dialog; ensure the close button (onClose) remains
keyboard-focusable and inside the dialog.


{/* Video / Thumbnail */}
<div className="px-10 pt-2 pb-4">
<VideoThumbnail entry={entry} />
</div>

{/* Description */}
<div className="px-10 pb-8">
<p className="text-base text-muted-foreground leading-relaxed">
{entry.expandedDescription ?? entry.description}
</p>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}
Loading
Loading