From ba4b98dcd408d1d38b466112205049a233362893 Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Sat, 3 Jan 2026 23:28:44 +0100
Subject: [PATCH 01/14] ci: enhance test workflow with manual trigger and
config change detection
- Add workflow_dispatch input to force run tests
- Include test configuration file changes in test detection logic
- Update .gitignore to ensure JetBrains IDE files are ignored
---
app/package.json | 2 +
app/src/components/Header.tsx | 29 +++
app/src/components/Layout.tsx | 70 ++++++
app/src/components/LibraryPills.tsx | 67 ++++++
app/src/components/SpecAccordions.tsx | 290 ++++++++++++++++++++++++
app/src/main.tsx | 4 +-
app/src/pages/CatalogPage.tsx | 296 ++++++++++++++++++++++++
app/src/pages/HomePage.tsx | 236 ++++++++++++++++++++
app/src/pages/SpecPage.tsx | 309 ++++++++++++++++++++++++++
app/src/router.tsx | 27 +++
app/yarn.lock | 58 ++++-
11 files changed, 1380 insertions(+), 8 deletions(-)
create mode 100644 app/src/components/Layout.tsx
create mode 100644 app/src/components/LibraryPills.tsx
create mode 100644 app/src/components/SpecAccordions.tsx
create mode 100644 app/src/pages/CatalogPage.tsx
create mode 100644 app/src/pages/HomePage.tsx
create mode 100644 app/src/pages/SpecPage.tsx
create mode 100644 app/src/router.tsx
diff --git a/app/package.json b/app/package.json
index be2ea33756..16e0290a73 100644
--- a/app/package.json
+++ b/app/package.json
@@ -22,6 +22,8 @@
"@mui/material": "^7.3.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
+ "react-helmet-async": "^2.0.5",
+ "react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0"
},
"devDependencies": {
diff --git a/app/src/components/Header.tsx b/app/src/components/Header.tsx
index 9947c50e87..14b4301695 100644
--- a/app/src/components/Header.tsx
+++ b/app/src/components/Header.tsx
@@ -1,4 +1,5 @@
import { memo, useState, useEffect } from 'react';
+import { Link as RouterLink } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Link from '@mui/material/Link';
@@ -7,6 +8,7 @@ import ClickAwayListener from '@mui/material/ClickAwayListener';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import ShuffleIcon from '@mui/icons-material/Shuffle';
+import ListIcon from '@mui/icons-material/List';
interface HeaderProps {
stats?: { specs: number; plots: number; libraries: number } | null;
@@ -187,6 +189,33 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) {
)}
{isXs ? '. copy. create.' : '. grab the code. make it yours.'}
+
+ {/* Catalog Link */}
+
+
+ catalog
+
);
});
diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx
new file mode 100644
index 0000000000..d4714bcbee
--- /dev/null
+++ b/app/src/components/Layout.tsx
@@ -0,0 +1,70 @@
+import { useState, useEffect, createContext, useContext } from 'react';
+import { Outlet } from 'react-router-dom';
+import Box from '@mui/material/Box';
+import Container from '@mui/material/Container';
+
+import { API_URL } from '../constants';
+import type { LibraryInfo, SpecInfo } from '../types';
+
+interface AppData {
+ specsData: SpecInfo[];
+ librariesData: LibraryInfo[];
+ stats: { specs: number; plots: number; libraries: number } | null;
+}
+
+const AppDataContext = createContext(null);
+
+export function useAppData() {
+ const context = useContext(AppDataContext);
+ if (!context) {
+ throw new Error('useAppData must be used within Layout');
+ }
+ return context;
+}
+
+export function Layout() {
+ const [specsData, setSpecsData] = useState([]);
+ const [librariesData, setLibrariesData] = useState([]);
+ const [stats, setStats] = useState<{ specs: number; plots: number; libraries: number } | null>(null);
+
+ // Load shared data on mount
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const [specsRes, libsRes, statsRes] = await Promise.all([
+ fetch(`${API_URL}/specs`),
+ fetch(`${API_URL}/libraries`),
+ fetch(`${API_URL}/stats`),
+ ]);
+
+ if (specsRes.ok) {
+ const data = await specsRes.json();
+ setSpecsData(Array.isArray(data) ? data : data.specs || []);
+ }
+
+ if (libsRes.ok) {
+ const data = await libsRes.json();
+ setLibrariesData(data.libraries || []);
+ }
+
+ if (statsRes.ok) {
+ const data = await statsRes.json();
+ setStats(data);
+ }
+ } catch (err) {
+ console.error('Error loading initial data:', err);
+ }
+ };
+ fetchData();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/components/LibraryPills.tsx b/app/src/components/LibraryPills.tsx
new file mode 100644
index 0000000000..40b6954470
--- /dev/null
+++ b/app/src/components/LibraryPills.tsx
@@ -0,0 +1,67 @@
+import { memo } from 'react';
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+
+interface Implementation {
+ library_id: string;
+ library_name: string;
+ quality_score: number | null;
+}
+
+interface LibraryPillsProps {
+ implementations: Implementation[];
+ selectedLibrary: string;
+ onSelect: (libraryId: string) => void;
+}
+
+export const LibraryPills = memo(function LibraryPills({
+ implementations,
+ selectedLibrary,
+ onSelect,
+}: LibraryPillsProps) {
+ return (
+
+ {implementations.map((impl) => {
+ const isSelected = impl.library_id === selectedLibrary;
+ const score = impl.quality_score;
+
+ return (
+ onSelect(impl.library_id)}
+ variant={isSelected ? 'filled' : 'outlined'}
+ sx={{
+ fontFamily: '"MonoLisa", monospace',
+ fontSize: '0.875rem',
+ fontWeight: isSelected ? 600 : 400,
+ bgcolor: isSelected ? '#3776AB' : 'transparent',
+ color: isSelected ? '#fff' : '#6b7280',
+ borderColor: isSelected ? '#3776AB' : '#d1d5db',
+ '&:hover': {
+ bgcolor: isSelected ? '#2c5f8a' : '#f3f4f6',
+ borderColor: '#3776AB',
+ },
+ transition: 'all 0.15s ease',
+ // Show quality score as subtle indicator
+ '&::after': score ? {
+ content: `"${Math.round(score)}"`,
+ fontSize: '0.65rem',
+ ml: 0.5,
+ opacity: 0.7,
+ } : undefined,
+ }}
+ />
+ );
+ })}
+
+ );
+});
diff --git a/app/src/components/SpecAccordions.tsx b/app/src/components/SpecAccordions.tsx
new file mode 100644
index 0000000000..74e4d99724
--- /dev/null
+++ b/app/src/components/SpecAccordions.tsx
@@ -0,0 +1,290 @@
+import { useState, useCallback } from 'react';
+import Box from '@mui/material/Box';
+import Accordion from '@mui/material/Accordion';
+import AccordionSummary from '@mui/material/AccordionSummary';
+import AccordionDetails from '@mui/material/AccordionDetails';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import Tooltip from '@mui/material/Tooltip';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import CodeIcon from '@mui/icons-material/Code';
+import DescriptionIcon from '@mui/icons-material/Description';
+import StarIcon from '@mui/icons-material/Star';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import CheckIcon from '@mui/icons-material/Check';
+
+// Import Prism for syntax highlighting (same as FullscreenModal)
+import Prism from 'prismjs';
+import 'prismjs/components/prism-python';
+import 'prismjs/themes/prism.css';
+
+interface SpecAccordionsProps {
+ code: string | null;
+ description: string;
+ applications?: string[];
+ notes?: string[];
+ qualityScore: number | null;
+ libraryId: string;
+ onTrackEvent?: (name: string, props?: Record) => void;
+}
+
+export function SpecAccordions({
+ code,
+ description,
+ applications,
+ notes,
+ qualityScore,
+ libraryId,
+ onTrackEvent,
+}: SpecAccordionsProps) {
+ const [copied, setCopied] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+
+ const handleCopy = useCallback(async () => {
+ if (!code) return;
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ onTrackEvent?.('copy_code', { library: libraryId, method: 'accordion' });
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error('Copy failed:', err);
+ }
+ }, [code, libraryId, onTrackEvent]);
+
+ const handleChange = (panel: string) => (_: React.SyntheticEvent, isExpanded: boolean) => {
+ setExpanded(isExpanded ? panel : false);
+ if (isExpanded) {
+ onTrackEvent?.(`expand_${panel}`, { library: libraryId });
+ }
+ };
+
+ // Highlight code with Prism
+ const highlightedCode = code
+ ? Prism.highlight(code, Prism.languages.python, 'python')
+ : '';
+
+ return (
+
+ {/* Code Accordion */}
+
+ }
+ sx={{
+ '& .MuiAccordionSummary-content': {
+ alignItems: 'center',
+ gap: 1,
+ },
+ }}
+ >
+
+
+ Code
+
+
+
+
+
+
+ {copied ? : }
+
+
+
+
+
+
+
+
+
+ {/* Description Accordion */}
+
+ }
+ sx={{
+ '& .MuiAccordionSummary-content': {
+ alignItems: 'center',
+ gap: 1,
+ },
+ }}
+ >
+
+
+ Description
+
+
+
+
+ {description}
+
+
+ {applications && applications.length > 0 && (
+
+
+ Applications
+
+
+ {applications.map((app, i) => (
+
+ {app}
+
+ ))}
+
+
+ )}
+
+ {notes && notes.length > 0 && (
+
+
+ Notes
+
+
+ {notes.map((note, i) => (
+
+ {note}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* Quality Accordion */}
+
+ }
+ sx={{
+ '& .MuiAccordionSummary-content': {
+ alignItems: 'center',
+ gap: 1,
+ },
+ }}
+ >
+
+
+ Quality: {qualityScore ? `${Math.round(qualityScore)}/100` : 'N/A'}
+
+
+
+
+ {qualityScore && qualityScore >= 90 ? (
+ <>This implementation scored {Math.round(qualityScore)}/100 in AI quality review.>
+ ) : qualityScore ? (
+ <>Quality score: {Math.round(qualityScore)}/100>
+ ) : (
+ 'Quality score not available.'
+ )}
+
+
+
+
+ );
+}
diff --git a/app/src/main.tsx b/app/src/main.tsx
index 3349aab9a2..16a6f259c4 100644
--- a/app/src/main.tsx
+++ b/app/src/main.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
-import App from './App';
+import { AppRouter } from './router';
// Import MonoLisa font - hosted on GCS (all text uses MonoLisa)
import './styles/fonts.css';
@@ -42,7 +42,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
-
+
);
diff --git a/app/src/pages/CatalogPage.tsx b/app/src/pages/CatalogPage.tsx
new file mode 100644
index 0000000000..60b1527ed2
--- /dev/null
+++ b/app/src/pages/CatalogPage.tsx
@@ -0,0 +1,296 @@
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { Link } from 'react-router-dom';
+import { Helmet } from 'react-helmet-async';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import Skeleton from '@mui/material/Skeleton';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+
+import { API_URL } from '../constants';
+import { useAnalytics } from '../hooks';
+import { useAppData } from '../components/Layout';
+import type { PlotImage } from '../types';
+
+interface CatalogSpec {
+ id: string;
+ title: string;
+ description?: string;
+ images: PlotImage[];
+}
+
+export function CatalogPage() {
+ const { specsData } = useAppData();
+ const { trackEvent } = useAnalytics();
+
+ const [allImages, setAllImages] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [rotationIndex, setRotationIndex] = useState>({});
+
+ // Fetch all images
+ useEffect(() => {
+ const fetchImages = async () => {
+ try {
+ const res = await fetch(`${API_URL}/plots/filter`);
+ if (res.ok) {
+ const data = await res.json();
+ setAllImages(data.images || []);
+ }
+ } catch (err) {
+ console.error('Error fetching images:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchImages();
+ }, []);
+
+ // Group images by spec_id and merge with spec metadata
+ const catalogSpecs = useMemo(() => {
+ // Group images by spec_id
+ const imagesBySpec: Record = {};
+ for (const img of allImages) {
+ const specId = img.spec_id || '';
+ if (!imagesBySpec[specId]) {
+ imagesBySpec[specId] = [];
+ }
+ imagesBySpec[specId].push(img);
+ }
+
+ // Merge with spec metadata
+ const specs: CatalogSpec[] = specsData
+ .filter((spec) => imagesBySpec[spec.id])
+ .map((spec) => ({
+ id: spec.id,
+ title: spec.title,
+ description: spec.description,
+ images: imagesBySpec[spec.id],
+ }));
+
+ // Sort alphabetically by title
+ specs.sort((a, b) => a.title.localeCompare(b.title));
+
+ return specs;
+ }, [allImages, specsData]);
+
+ // Handle image click - rotate to next implementation
+ const handleImageClick = useCallback(
+ (specId: string, totalImages: number) => {
+ setRotationIndex((prev) => ({
+ ...prev,
+ [specId]: ((prev[specId] || 0) + 1) % totalImages,
+ }));
+ trackEvent('catalog_rotate', { spec: specId });
+ },
+ [trackEvent]
+ );
+
+ if (loading) {
+ return (
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+ <>
+
+ Catalog | pyplots.ai
+
+
+
+
+
+
+ {/* Back Button */}
+ }
+ sx={{
+ color: '#6b7280',
+ mb: 3,
+ fontFamily: '"MonoLisa", monospace',
+ textTransform: 'none',
+ '&:hover': { color: '#3776AB', bgcolor: 'transparent' },
+ }}
+ >
+ Back
+
+
+ {/* Title */}
+
+ Catalog
+
+ {catalogSpecs.length} examples
+
+
+
+ {/* Spec List */}
+
+ {catalogSpecs.map((spec) => {
+ const currentIndex = rotationIndex[spec.id] || 0;
+ const currentImage = spec.images[currentIndex];
+
+ return (
+
+ {/* Image - Click to rotate */}
+ handleImageClick(spec.id, spec.images.length)}
+ sx={{
+ position: 'relative',
+ width: 280,
+ height: 158,
+ flexShrink: 0,
+ borderRadius: 1,
+ overflow: 'hidden',
+ bgcolor: '#f3f4f6',
+ cursor: spec.images.length > 1 ? 'pointer' : 'default',
+ '&:hover .rotate-hint': {
+ opacity: spec.images.length > 1 ? 1 : 0,
+ },
+ }}
+ >
+ {currentImage && (
+
+ )}
+
+ {/* Rotation hint badge */}
+ {spec.images.length > 1 && (
+
+ {currentIndex + 1}/{spec.images.length}
+
+ )}
+
+ {/* Current library badge */}
+
+ {currentImage?.library}
+
+
+
+ {/* Text - Click to navigate */}
+
+
+ {spec.title}
+
+ {spec.description && (
+
+ {spec.description}
+
+ )}
+
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/app/src/pages/HomePage.tsx b/app/src/pages/HomePage.tsx
new file mode 100644
index 0000000000..dad29a61c3
--- /dev/null
+++ b/app/src/pages/HomePage.tsx
@@ -0,0 +1,236 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import Box from '@mui/material/Box';
+import Alert from '@mui/material/Alert';
+import Fab from '@mui/material/Fab';
+import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
+
+import type { PlotImage } from '../types';
+import type { ImageSize } from '../constants';
+import { useInfiniteScroll, useAnalytics, useFilterState, isFiltersEmpty } from '../hooks';
+import { Header, Footer, FilterBar, ImagesGrid, FullscreenModal } from '../components';
+import { useAppData } from '../components/Layout';
+
+export function HomePage() {
+ const navigate = useNavigate();
+ const { specsData, librariesData, stats } = useAppData();
+
+ // Handle scroll restoration on back navigation
+ useEffect(() => {
+ if ('scrollRestoration' in history) {
+ history.scrollRestoration = 'manual';
+ }
+
+ // Check for saved scroll position from back navigation
+ const savedScrollY = sessionStorage.getItem('homeScrollY');
+ if (savedScrollY) {
+ // Delay scroll restoration to allow images to load
+ const timer = setTimeout(() => {
+ window.scrollTo(0, parseInt(savedScrollY, 10));
+ // Clear the saved position
+ sessionStorage.removeItem('homeScrollY');
+ }, 150);
+ return () => clearTimeout(timer);
+ } else {
+ window.scrollTo(0, 0);
+ }
+ }, []);
+
+ // Custom hooks
+ const { trackPageview, trackEvent } = useAnalytics();
+
+ const {
+ activeFilters,
+ filterCounts,
+ orCounts,
+ allImages,
+ displayedImages,
+ hasMore,
+ loading,
+ error,
+ setDisplayedImages,
+ setHasMore,
+ handleAddFilter,
+ handleAddValueToGroup,
+ handleRemoveFilter,
+ handleRemoveGroup,
+ handleRandom,
+ randomAnimation,
+ } = useFilterState({
+ onTrackPageview: trackPageview,
+ onTrackEvent: trackEvent,
+ });
+
+ const { loadMoreRef } = useInfiniteScroll({
+ allImages,
+ displayedImages,
+ hasMore,
+ setDisplayedImages,
+ setHasMore,
+ });
+
+ // UI state
+ const [modalImage, setModalImage] = useState(null);
+ const [openImageTooltip, setOpenImageTooltip] = useState(null);
+ const [imageSize, setImageSize] = useState(() => {
+ const stored = localStorage.getItem('imageSize');
+ return stored === 'normal' || stored === 'compact' ? stored : 'normal';
+ });
+ const [showScrollTop, setShowScrollTop] = useState(false);
+
+ // Refs
+ const searchInputRef = useRef(null);
+
+ // Persist imageSize to localStorage
+ useEffect(() => {
+ localStorage.setItem('imageSize', imageSize);
+ }, [imageSize]);
+
+ // Show/hide scroll-to-top button based on scroll position
+ useEffect(() => {
+ const handleScroll = () => {
+ setShowScrollTop(window.scrollY > 300);
+ };
+ window.addEventListener('scroll', handleScroll);
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ // Handle card click - navigate to spec page
+ const handleCardClick = useCallback(
+ (img: PlotImage) => {
+ if (document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur();
+ }
+
+ // Save scroll position for back navigation
+ sessionStorage.setItem('homeScrollY', String(window.scrollY));
+
+ // Navigate to spec page
+ const specId = img.spec_id || '';
+ const library = img.library;
+ navigate(`/${specId}/${library}`);
+ trackEvent('navigate_to_spec', { spec: specId, library });
+ },
+ [navigate, trackEvent]
+ );
+
+ // Close tooltip when clicking anywhere
+ const handleContainerClick = useCallback(
+ (e: React.MouseEvent) => {
+ const target = e.target as HTMLElement;
+ if (target.closest('[data-description-btn]')) return;
+ if (openImageTooltip) setOpenImageTooltip(null);
+ },
+ [openImageTooltip]
+ );
+
+ // Global keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement;
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || modalImage) return;
+
+ if (e.key === ' ') {
+ e.preventDefault();
+ handleRandom('space');
+ } else if (e.key === 'Enter' && searchInputRef.current) {
+ e.preventDefault();
+ searchInputRef.current.focus();
+ } else if (e.key === 'Backspace' && activeFilters.length > 0) {
+ e.preventDefault();
+ handleRemoveGroup(activeFilters.length - 1);
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [handleRandom, handleRemoveGroup, activeFilters.length, modalImage]);
+
+ // Get selected spec/library for compatibility with existing components
+ const specFilter = activeFilters.find((f) => f.category === 'spec');
+ const libFilter = activeFilters.find((f) => f.category === 'lib');
+ const selectedSpec = specFilter?.values[0] || '';
+ const selectedLibrary = libFilter?.values[0] || '';
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ {!loading && allImages.length === 0 && !isFiltersEmpty(activeFilters) && (
+
+ No plots match these filters.
+
+ )}
+
+
+
+ setModalImage(null)}
+ onTrackEvent={trackEvent}
+ />
+
+ {/* Floating scroll-to-top button */}
+ window.scrollTo({ top: 0, behavior: 'smooth' })}
+ sx={{
+ position: 'fixed',
+ bottom: 24,
+ right: 24,
+ bgcolor: '#f3f4f6',
+ color: '#6b7280',
+ opacity: showScrollTop ? 1 : 0,
+ visibility: showScrollTop ? 'visible' : 'hidden',
+ transition: 'opacity 0.3s, visibility 0.3s',
+ '&:hover': { bgcolor: '#e5e7eb', color: '#3776AB' },
+ }}
+ >
+
+
+
+ );
+}
diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx
new file mode 100644
index 0000000000..271120ff1f
--- /dev/null
+++ b/app/src/pages/SpecPage.tsx
@@ -0,0 +1,309 @@
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import { Helmet } from 'react-helmet-async';
+import Box from '@mui/material/Box';
+import Typography from '@mui/material/Typography';
+import Button from '@mui/material/Button';
+import IconButton from '@mui/material/IconButton';
+import Tooltip from '@mui/material/Tooltip';
+import Skeleton from '@mui/material/Skeleton';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import DownloadIcon from '@mui/icons-material/Download';
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+
+import { API_URL } from '../constants';
+import { useAnalytics } from '../hooks';
+import { LibraryPills } from '../components/LibraryPills';
+import { SpecAccordions } from '../components/SpecAccordions';
+import { Footer } from '../components';
+
+interface Implementation {
+ library_id: string;
+ library_name: string;
+ preview_url: string;
+ preview_thumb?: string;
+ preview_html?: string;
+ quality_score: number | null;
+ code: string | null;
+ generated_at?: string;
+ library_version?: string;
+}
+
+interface SpecDetail {
+ id: string;
+ title: string;
+ description: string;
+ applications?: string[];
+ data?: string[];
+ notes?: string[];
+ tags?: Record;
+ implementations: Implementation[];
+}
+
+export function SpecPage() {
+ const { specId, library: urlLibrary } = useParams();
+ const navigate = useNavigate();
+ const { trackEvent } = useAnalytics();
+
+ const [specData, setSpecData] = useState(null);
+ const [selectedLibrary, setSelectedLibrary] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [imageLoaded, setImageLoaded] = useState(false);
+
+ // Fetch spec data
+ useEffect(() => {
+ if (!specId) return;
+
+ const fetchSpec = async () => {
+ setLoading(true);
+ setError(null);
+ setImageLoaded(false);
+
+ try {
+ const res = await fetch(`${API_URL}/specs/${specId}`);
+ if (!res.ok) {
+ if (res.status === 404) {
+ setError('Spec not found');
+ } else {
+ setError('Failed to load spec');
+ }
+ return;
+ }
+
+ const data: SpecDetail = await res.json();
+ setSpecData(data);
+
+ // Set selected library
+ if (urlLibrary && data.implementations.some((impl) => impl.library_id === urlLibrary)) {
+ setSelectedLibrary(urlLibrary);
+ } else if (data.implementations.length > 0) {
+ // Pick random implementation
+ const randomIdx = Math.floor(Math.random() * data.implementations.length);
+ const randomLib = data.implementations[randomIdx].library_id;
+ setSelectedLibrary(randomLib);
+
+ // Update URL to include the library (without adding to history)
+ if (!urlLibrary) {
+ navigate(`/${specId}/${randomLib}`, { replace: true });
+ }
+ }
+ } catch (err) {
+ console.error('Error fetching spec:', err);
+ setError('Failed to load spec');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchSpec();
+ }, [specId, urlLibrary, navigate]);
+
+ // Get current implementation
+ const currentImpl = useMemo(() => {
+ if (!specData || !selectedLibrary) return null;
+ return specData.implementations.find((impl) => impl.library_id === selectedLibrary) || null;
+ }, [specData, selectedLibrary]);
+
+ // Handle library switch
+ const handleLibrarySelect = useCallback(
+ (libraryId: string) => {
+ setSelectedLibrary(libraryId);
+ setImageLoaded(false);
+ navigate(`/${specId}/${libraryId}`, { replace: true });
+ trackEvent('switch_library', { spec: specId, library: libraryId });
+ },
+ [specId, navigate, trackEvent]
+ );
+
+ // Handle download
+ const handleDownload = useCallback(() => {
+ if (!currentImpl?.preview_url) return;
+ const link = document.createElement('a');
+ link.href = currentImpl.preview_url;
+ link.download = `${specId}-${selectedLibrary}.png`;
+ link.click();
+ trackEvent('download_image', { spec: specId, library: selectedLibrary || undefined });
+ }, [currentImpl, specId, selectedLibrary, trackEvent]);
+
+ // Track page view
+ useEffect(() => {
+ if (specData && selectedLibrary) {
+ trackEvent('view_spec', { spec: specId, library: selectedLibrary });
+ }
+ }, [specData, selectedLibrary, specId, trackEvent]);
+
+ if (loading) {
+ return (
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (error || !specData) {
+ return (
+
+
+ {error || 'Spec not found'}
+
+ } sx={{ color: '#3776AB' }}>
+ Back to Home
+
+
+ );
+ }
+
+ return (
+ <>
+
+ {`${specData.title} - ${selectedLibrary} | pyplots.ai`}
+
+
+
+ {currentImpl?.preview_url && }
+
+
+
+
+ {/* Back Button */}
+ }
+ sx={{
+ color: '#6b7280',
+ mb: 2,
+ fontFamily: '"MonoLisa", monospace',
+ textTransform: 'none',
+ '&:hover': { color: '#3776AB', bgcolor: 'transparent' },
+ }}
+ >
+ Back
+
+
+ {/* Title */}
+
+ {specData.title}
+
+
+ {/* Library Pills */}
+
+
+ {/* Main Image */}
+
+ {!imageLoaded && (
+
+ )}
+ {currentImpl?.preview_url && (
+ setImageLoaded(true)}
+ sx={{
+ width: '100%',
+ height: '100%',
+ objectFit: 'contain',
+ display: imageLoaded ? 'block' : 'none',
+ }}
+ />
+ )}
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ {currentImpl?.preview_html && (
+
+ trackEvent('open_interactive', { spec: specId, library: selectedLibrary || undefined })}
+ sx={{
+ bgcolor: 'rgba(255,255,255,0.9)',
+ '&:hover': { bgcolor: '#fff' },
+ }}
+ size="small"
+ >
+
+
+
+ )}
+
+
+
+ {/* Accordions */}
+
+
+ {/* Footer */}
+
+
+ >
+ );
+}
diff --git a/app/src/router.tsx b/app/src/router.tsx
new file mode 100644
index 0000000000..921d30cc60
--- /dev/null
+++ b/app/src/router.tsx
@@ -0,0 +1,27 @@
+import { createBrowserRouter, RouterProvider } from 'react-router-dom';
+import { HelmetProvider } from 'react-helmet-async';
+import { Layout } from './components/Layout';
+import { HomePage } from './pages/HomePage';
+import { SpecPage } from './pages/SpecPage';
+import { CatalogPage } from './pages/CatalogPage';
+
+const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'catalog', element: },
+ { path: ':specId', element: },
+ { path: ':specId/:library', element: },
+ ],
+ },
+]);
+
+export function AppRouter() {
+ return (
+
+
+
+ );
+}
diff --git a/app/yarn.lock b/app/yarn.lock
index ae54075ca9..63c5bdcfbc 100644
--- a/app/yarn.lock
+++ b/app/yarn.lock
@@ -324,11 +324,6 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz#5efe5a112938b1180e98c76685ff9185cfa4f16e"
integrity sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==
-"@fontsource/inter@^5.2.8":
- version "5.2.8"
- resolved "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz"
- integrity sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==
-
"@jridgewell/gen-mapping@^0.3.12":
version "0.3.13"
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz"
@@ -753,6 +748,11 @@ convert-source-map@^1.5.0:
resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz"
integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
+cookie@^1.0.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c"
+ integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==
+
cosmiconfig@^7.0.0:
version "7.1.0"
resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz"
@@ -917,6 +917,13 @@ import-fresh@^3.2.1:
parent-module "^1.0.0"
resolve-from "^4.0.0"
+invariant@^2.2.4:
+ version "2.2.4"
+ resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
+ integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
+ dependencies:
+ loose-envify "^1.0.0"
+
is-alphabetical@^2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz"
@@ -972,7 +979,7 @@ lines-and-columns@^1.1.6:
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
-loose-envify@^1.4.0:
+loose-envify@^1.0.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -1087,6 +1094,20 @@ react-dom@^19.2.3:
dependencies:
scheduler "^0.27.0"
+react-fast-compare@^3.2.2:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
+ integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
+
+react-helmet-async@^2.0.5:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-2.0.5.tgz#cfc70cd7bb32df7883a8ed55502a1513747223ec"
+ integrity sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==
+ dependencies:
+ invariant "^2.2.4"
+ react-fast-compare "^3.2.2"
+ shallowequal "^1.1.0"
+
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
@@ -1097,6 +1118,21 @@ react-is@^19.2.0:
resolved "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz"
integrity sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==
+react-router-dom@^7.11.0:
+ version "7.11.0"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.11.0.tgz#2165f63e52798bd0eb138480c098ad058cdf3413"
+ integrity sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==
+ dependencies:
+ react-router "7.11.0"
+
+react-router@7.11.0:
+ version "7.11.0"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.11.0.tgz#d3b91567fdbe910caf9064ea69b7b4d9264f2945"
+ integrity sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==
+ dependencies:
+ cookie "^1.0.1"
+ set-cookie-parser "^2.6.0"
+
react-syntax-highlighter@^16.1.0:
version "16.1.0"
resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz#ebe0bb5ae7a3540859212cedafd767f0189c516c"
@@ -1184,6 +1220,16 @@ scheduler@^0.27.0:
resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz"
integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
+set-cookie-parser@^2.6.0:
+ version "2.7.2"
+ resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68"
+ integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
+
+shallowequal@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
+ integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==
+
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
From 0024ae662f2238199c1dda3af2f40d71d7ccf07b Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Sat, 3 Jan 2026 23:57:30 +0100
Subject: [PATCH 02/14] ci: enhance test workflow with manual trigger and
config change detection
- Add workflow_dispatch input to force run tests
- Include test configuration file changes in test detection logic
- Update .gitignore to ensure JetBrains IDE files are ignored
---
app/src/components/ImagesGrid.tsx | 18 +-
app/src/components/Layout.tsx | 2 +-
app/src/components/LibraryPills.tsx | 9 +-
app/src/components/SpecTabs.tsx | 267 ++++++++++++++++++++++++++++
app/src/pages/CatalogPage.tsx | 30 +++-
app/src/pages/SpecPage.tsx | 239 ++++++++++++++++++-------
6 files changed, 487 insertions(+), 78 deletions(-)
create mode 100644 app/src/components/SpecTabs.tsx
diff --git a/app/src/components/ImagesGrid.tsx b/app/src/components/ImagesGrid.tsx
index 3e87e1bc26..9c14d82c43 100644
--- a/app/src/components/ImagesGrid.tsx
+++ b/app/src/components/ImagesGrid.tsx
@@ -115,14 +115,14 @@ export function ImagesGrid({
No images found for this spec.
) : (
-
+
{images.map((image, index) => {
const lib = libraryMap.get(image.library);
const spec = specMap.get(image.spec_id || '');
@@ -148,7 +148,7 @@ export function ImagesGrid({
);
})}
-
+
)}
{/* Load more trigger (invisible) */}
{hasMore && (
diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx
index d4714bcbee..3ba69b2038 100644
--- a/app/src/components/Layout.tsx
+++ b/app/src/components/Layout.tsx
@@ -61,7 +61,7 @@ export function Layout() {
return (
-
+
diff --git a/app/src/components/LibraryPills.tsx b/app/src/components/LibraryPills.tsx
index 40b6954470..89bd46efcd 100644
--- a/app/src/components/LibraryPills.tsx
+++ b/app/src/components/LibraryPills.tsx
@@ -24,9 +24,15 @@ export const LibraryPills = memo(function LibraryPills({
sx={{
display: 'flex',
gap: 1,
- flexWrap: 'wrap',
justifyContent: 'center',
py: 2,
+ overflowX: 'auto',
+ overflowY: 'hidden',
+ mx: -2,
+ px: 2,
+ // Hide scrollbar but keep functionality
+ scrollbarWidth: 'none',
+ '&::-webkit-scrollbar': { display: 'none' },
}}
>
{implementations.map((impl) => {
@@ -40,6 +46,7 @@ export const LibraryPills = memo(function LibraryPills({
onClick={() => onSelect(impl.library_id)}
variant={isSelected ? 'filled' : 'outlined'}
sx={{
+ flexShrink: 0,
fontFamily: '"MonoLisa", monospace',
fontSize: '0.875rem',
fontWeight: isSelected ? 600 : 400,
diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx
new file mode 100644
index 0000000000..e2779f8fec
--- /dev/null
+++ b/app/src/components/SpecTabs.tsx
@@ -0,0 +1,267 @@
+import { useState, useCallback } from 'react';
+import Box from '@mui/material/Box';
+import Tabs from '@mui/material/Tabs';
+import Tab from '@mui/material/Tab';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import Tooltip from '@mui/material/Tooltip';
+import CodeIcon from '@mui/icons-material/Code';
+import DescriptionIcon from '@mui/icons-material/Description';
+import StarIcon from '@mui/icons-material/Star';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import CheckIcon from '@mui/icons-material/Check';
+
+// Import Prism for syntax highlighting
+import Prism from 'prismjs';
+import 'prismjs/components/prism-python';
+import 'prismjs/themes/prism.css';
+
+interface SpecTabsProps {
+ code: string | null;
+ description: string;
+ applications?: string[];
+ notes?: string[];
+ qualityScore: number | null;
+ libraryId: string;
+ onTrackEvent?: (name: string, props?: Record) => void;
+}
+
+interface TabPanelProps {
+ children?: React.ReactNode;
+ index: number;
+ value: number;
+}
+
+function TabPanel({ children, value, index }: TabPanelProps) {
+ return (
+
+ {value === index && children}
+
+ );
+}
+
+export function SpecTabs({
+ code,
+ description,
+ applications,
+ notes,
+ qualityScore,
+ libraryId,
+ onTrackEvent,
+}: SpecTabsProps) {
+ const [copied, setCopied] = useState(false);
+ const [tabIndex, setTabIndex] = useState(0);
+
+ const handleCopy = useCallback(async () => {
+ if (!code) return;
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ onTrackEvent?.('copy_code', { library: libraryId, method: 'tab' });
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error('Copy failed:', err);
+ }
+ }, [code, libraryId, onTrackEvent]);
+
+ const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
+ setTabIndex(newValue);
+ const tabNames = ['code', 'description', 'quality'];
+ onTrackEvent?.(`view_${tabNames[newValue]}`, { library: libraryId });
+ };
+
+ // Highlight code with Prism
+ const highlightedCode = code
+ ? Prism.highlight(code, Prism.languages.python, 'python')
+ : '';
+
+ return (
+
+
+
+ }
+ iconPosition="start"
+ label="Code"
+ />
+ }
+ iconPosition="start"
+ label="Info"
+ />
+ }
+ iconPosition="start"
+ label={qualityScore ? `${Math.round(qualityScore)}` : 'N/A'}
+ />
+
+
+
+ {/* Code Tab */}
+
+
+
+
+ {copied ? : }
+
+
+
+
+
+
+
+
+ {/* Description Tab */}
+
+
+ {description}
+
+
+ {applications && applications.length > 0 && (
+
+
+ Applications
+
+
+ {applications.map((app, i) => (
+
+ {app}
+
+ ))}
+
+
+ )}
+
+ {notes && notes.length > 0 && (
+
+
+ Notes
+
+
+ {notes.map((note, i) => (
+
+ {note}
+
+ ))}
+
+
+ )}
+
+
+ {/* Quality Tab */}
+
+
+ {qualityScore && qualityScore >= 90 ? (
+ <>This implementation scored {Math.round(qualityScore)}/100 in AI quality review.>
+ ) : qualityScore ? (
+ <>Quality score: {Math.round(qualityScore)}/100>
+ ) : (
+ 'Quality score not available.'
+ )}
+
+
+
+ );
+}
diff --git a/app/src/pages/CatalogPage.tsx b/app/src/pages/CatalogPage.tsx
index 60b1527ed2..62664dca10 100644
--- a/app/src/pages/CatalogPage.tsx
+++ b/app/src/pages/CatalogPage.tsx
@@ -57,14 +57,14 @@ export function CatalogPage() {
imagesBySpec[specId].push(img);
}
- // Merge with spec metadata
+ // Merge with spec metadata and sort images by library name
const specs: CatalogSpec[] = specsData
.filter((spec) => imagesBySpec[spec.id])
.map((spec) => ({
id: spec.id,
title: spec.title,
description: spec.description,
- images: imagesBySpec[spec.id],
+ images: imagesBySpec[spec.id].sort((a, b) => a.library.localeCompare(b.library)),
}));
// Sort alphabetically by title
@@ -73,6 +73,17 @@ export function CatalogPage() {
return specs;
}, [allImages, specsData]);
+ // Initialize random rotation indices once specs are loaded
+ useEffect(() => {
+ if (catalogSpecs.length > 0 && Object.keys(rotationIndex).length === 0) {
+ const initialIndices: Record = {};
+ catalogSpecs.forEach((spec) => {
+ initialIndices[spec.id] = Math.floor(Math.random() * spec.images.length);
+ });
+ setRotationIndex(initialIndices);
+ }
+ }, [catalogSpecs, rotationIndex]);
+
// Handle image click - rotate to next implementation
const handleImageClick = useCallback(
(specId: string, totalImages: number) => {
@@ -165,7 +176,8 @@ export function CatalogPage() {
key={spec.id}
sx={{
display: 'flex',
- gap: 3,
+ flexDirection: { xs: 'column', sm: 'row' },
+ gap: { xs: 2, sm: 3 },
p: 2,
bgcolor: '#fff',
borderRadius: 2,
@@ -181,8 +193,8 @@ export function CatalogPage() {
onClick={() => handleImageClick(spec.id, spec.images.length)}
sx={{
position: 'relative',
- width: 280,
- height: 158,
+ width: { xs: '100%', sm: 280 },
+ height: { xs: 180, sm: 158 },
flexShrink: 0,
borderRadius: 1,
overflow: 'hidden',
@@ -191,6 +203,9 @@ export function CatalogPage() {
'&:hover .rotate-hint': {
opacity: spec.images.length > 1 ? 1 : 0,
},
+ '&:hover .library-hint': {
+ opacity: 1,
+ },
}}
>
{currentImage && (
@@ -231,17 +246,20 @@ export function CatalogPage() {
{/* Current library badge */}
{currentImage?.library}
diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx
index 271120ff1f..ddc03d9d36 100644
--- a/app/src/pages/SpecPage.tsx
+++ b/app/src/pages/SpecPage.tsx
@@ -8,13 +8,15 @@ import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Skeleton from '@mui/material/Skeleton';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import DownloadIcon from '@mui/icons-material/Download';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { API_URL } from '../constants';
import { useAnalytics } from '../hooks';
import { LibraryPills } from '../components/LibraryPills';
-import { SpecAccordions } from '../components/SpecAccordions';
+import { SpecTabs } from '../components/SpecTabs';
import { Footer } from '../components';
interface Implementation {
@@ -99,12 +101,23 @@ export function SpecPage() {
fetchSpec();
}, [specId, urlLibrary, navigate]);
- // Get current implementation
+ // Get sorted implementations (alphabetical)
+ const sortedImpls = useMemo(() => {
+ if (!specData) return [];
+ return [...specData.implementations].sort((a, b) => a.library_id.localeCompare(b.library_id));
+ }, [specData]);
+
+ // Get current implementation and index
const currentImpl = useMemo(() => {
if (!specData || !selectedLibrary) return null;
return specData.implementations.find((impl) => impl.library_id === selectedLibrary) || null;
}, [specData, selectedLibrary]);
+ const currentIndex = useMemo(() => {
+ if (!selectedLibrary) return 0;
+ return sortedImpls.findIndex((impl) => impl.library_id === selectedLibrary);
+ }, [sortedImpls, selectedLibrary]);
+
// Handle library switch
const handleLibrarySelect = useCallback(
(libraryId: string) => {
@@ -116,6 +129,19 @@ export function SpecPage() {
[specId, navigate, trackEvent]
);
+ // Navigate to prev/next library
+ const handlePrevLibrary = useCallback(() => {
+ if (sortedImpls.length === 0) return;
+ const newIndex = currentIndex <= 0 ? sortedImpls.length - 1 : currentIndex - 1;
+ handleLibrarySelect(sortedImpls[newIndex].library_id);
+ }, [sortedImpls, currentIndex, handleLibrarySelect]);
+
+ const handleNextLibrary = useCallback(() => {
+ if (sortedImpls.length === 0) return;
+ const newIndex = currentIndex >= sortedImpls.length - 1 ? 0 : currentIndex + 1;
+ handleLibrarySelect(sortedImpls[newIndex].library_id);
+ }, [sortedImpls, currentIndex, handleLibrarySelect]);
+
// Handle download
const handleDownload = useCallback(() => {
if (!currentImpl?.preview_url) return;
@@ -202,96 +228,187 @@ export function SpecPage() {
{specData.title}
- {/* Library Pills */}
+ {/* Description */}
+
+ {specData.description}
+
+
+ {/* Library Carousel */}
- {/* Main Image */}
+ {/* Main Image with Navigation */}
- {!imageLoaded && (
-
- )}
- {currentImpl?.preview_url && (
- setImageLoaded(true)}
- sx={{
- width: '100%',
- height: '100%',
- objectFit: 'contain',
- display: imageLoaded ? 'block' : 'none',
- }}
- />
- )}
+ {/* Left Arrow */}
+
+
+
- {/* Action Buttons */}
+ {/* Image Container */}
-
-
+ )}
+ {currentImpl?.preview_url && (
+ setImageLoaded(true)}
+ sx={{
+ width: '100%',
+ height: '100%',
+ objectFit: 'contain',
+ display: imageLoaded ? 'block' : 'none',
+ }}
+ />
+ )}
+
+ {/* Library Badge (top-left) */}
+
+
+ {selectedLibrary}
+
+
-
-
-
- {currentImpl?.preview_html && (
-
+ {currentIndex + 1}/{sortedImpls.length}
+
+
+
+ {/* Action Buttons (top-right) */}
+
+
trackEvent('open_interactive', { spec: specId, library: selectedLibrary || undefined })}
+ onClick={handleDownload}
sx={{
bgcolor: 'rgba(255,255,255,0.9)',
'&:hover': { bgcolor: '#fff' },
}}
size="small"
>
-
+
- )}
+ {currentImpl?.preview_html && (
+
+ trackEvent('open_interactive', { spec: specId, library: selectedLibrary || undefined })}
+ sx={{
+ bgcolor: 'rgba(255,255,255,0.9)',
+ '&:hover': { bgcolor: '#fff' },
+ }}
+ size="small"
+ >
+
+
+
+ )}
+
+
+ {/* Right Arrow */}
+
+
+
- {/* Accordions */}
-
Date: Sun, 4 Jan 2026 00:32:44 +0100
Subject: [PATCH 03/14] ci: enhance test workflow with manual trigger and
config change detection
- Add workflow_dispatch input to force run tests
- Include test configuration file changes in test detection logic
- Update .gitignore to ensure JetBrains IDE files are ignored
---
api/routers/specs.py | 7 +
api/schemas.py | 8 +
app/src/components/LibraryPills.tsx | 193 +++++++---
app/src/components/SpecTabs.tsx | 522 +++++++++++++++++++++-------
app/src/pages/SpecPage.tsx | 276 +++++++--------
5 files changed, 678 insertions(+), 328 deletions(-)
diff --git a/api/routers/specs.py b/api/routers/specs.py
index b013eeef63..edb20162bb 100644
--- a/api/routers/specs.py
+++ b/api/routers/specs.py
@@ -81,6 +81,11 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
generated_by=impl.generated_by,
python_version=impl.python_version,
library_version=impl.library_version,
+ review_strengths=impl.review_strengths or [],
+ review_weaknesses=impl.review_weaknesses or [],
+ review_image_description=impl.review_image_description,
+ review_criteria_checklist=impl.review_criteria_checklist,
+ review_verdict=impl.review_verdict,
)
for impl in spec.impls
]
@@ -95,6 +100,8 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
tags=spec.tags,
issue=spec.issue,
suggested=spec.suggested,
+ created=spec.created.isoformat() if spec.created else None,
+ updated=spec.updated.isoformat() if spec.updated else None,
implementations=impls,
)
set_cache(key, result)
diff --git a/api/schemas.py b/api/schemas.py
index 4dac2e63c2..236c42a92d 100644
--- a/api/schemas.py
+++ b/api/schemas.py
@@ -23,6 +23,12 @@ class ImplementationResponse(BaseModel):
generated_by: Optional[str] = None
python_version: Optional[str] = None
library_version: Optional[str] = None
+ # Review fields
+ review_strengths: list[str] = []
+ review_weaknesses: list[str] = []
+ review_image_description: Optional[str] = None
+ review_criteria_checklist: Optional[dict] = None
+ review_verdict: Optional[str] = None
class SpecDetailResponse(BaseModel):
@@ -37,6 +43,8 @@ class SpecDetailResponse(BaseModel):
tags: Optional[dict] = None
issue: Optional[int] = None
suggested: Optional[str] = None
+ created: Optional[str] = None
+ updated: Optional[str] = None
implementations: list[ImplementationResponse] = []
diff --git a/app/src/components/LibraryPills.tsx b/app/src/components/LibraryPills.tsx
index 89bd46efcd..9e8d8a02ba 100644
--- a/app/src/components/LibraryPills.tsx
+++ b/app/src/components/LibraryPills.tsx
@@ -1,6 +1,21 @@
-import { memo } from 'react';
+import { memo, useMemo } from 'react';
import Box from '@mui/material/Box';
-import Chip from '@mui/material/Chip';
+import IconButton from '@mui/material/IconButton';
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+
+// Library abbreviations (same as filter display)
+const LIBRARY_ABBREV: Record = {
+ matplotlib: 'mpl',
+ seaborn: 'sns',
+ plotly: 'ply',
+ bokeh: 'bok',
+ altair: 'alt',
+ plotnine: 'p9',
+ pygal: 'pyg',
+ highcharts: 'hc',
+ letsplot: 'lp',
+};
interface Implementation {
library_id: string;
@@ -19,56 +34,148 @@ export const LibraryPills = memo(function LibraryPills({
selectedLibrary,
onSelect,
}: LibraryPillsProps) {
+ // Sort implementations alphabetically
+ const sortedImpls = useMemo(() => {
+ return [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id));
+ }, [implementations]);
+
+ // Get current index
+ const currentIndex = useMemo(() => {
+ const idx = sortedImpls.findIndex((impl) => impl.library_id === selectedLibrary);
+ return idx >= 0 ? idx : 0;
+ }, [sortedImpls, selectedLibrary]);
+
+ // Get visible items (prev, current, next) with wrap-around
+ const visibleItems = useMemo(() => {
+ const len = sortedImpls.length;
+ if (len === 0) return [];
+ if (len === 1) return [{ impl: sortedImpls[0], position: 'center' as const }];
+ if (len === 2) {
+ return [
+ { impl: sortedImpls[(currentIndex - 1 + len) % len], position: 'left' as const },
+ { impl: sortedImpls[currentIndex], position: 'center' as const },
+ ];
+ }
+
+ const prevIdx = (currentIndex - 1 + len) % len;
+ const nextIdx = (currentIndex + 1) % len;
+
+ return [
+ { impl: sortedImpls[prevIdx], position: 'left' as const },
+ { impl: sortedImpls[currentIndex], position: 'center' as const },
+ { impl: sortedImpls[nextIdx], position: 'right' as const },
+ ];
+ }, [sortedImpls, currentIndex]);
+
+ const handlePrev = () => {
+ const len = sortedImpls.length;
+ const newIndex = (currentIndex - 1 + len) % len;
+ onSelect(sortedImpls[newIndex].library_id);
+ };
+
+ const handleNext = () => {
+ const len = sortedImpls.length;
+ const newIndex = (currentIndex + 1) % len;
+ onSelect(sortedImpls[newIndex].library_id);
+ };
+
+ if (sortedImpls.length === 0) return null;
+
return (
- {implementations.map((impl) => {
- const isSelected = impl.library_id === selectedLibrary;
- const score = impl.quality_score;
+ {/* Left Arrow */}
+
+
+
+
+ {/* Pills Container */}
+
+ {visibleItems.map(({ impl, position }) => {
+ const isCenter = position === 'center';
+ const score = impl.quality_score;
+
+ return (
+ onSelect(impl.library_id)}
+ title={!isCenter ? impl.library_id : undefined}
+ sx={{
+ px: 1.5,
+ py: 0.5,
+ borderRadius: 2,
+ fontFamily: '"MonoLisa", monospace',
+ fontSize: '0.85rem',
+ fontWeight: isCenter ? 600 : 400,
+ bgcolor: '#f3f4f6',
+ border: isCenter ? '1px solid #3776AB' : '1px solid transparent',
+ color: isCenter ? '#374151' : '#9ca3af',
+ cursor: 'pointer',
+ transition: 'all 0.2s ease',
+ whiteSpace: 'nowrap',
+ '&:hover': {
+ bgcolor: '#e5e7eb',
+ borderColor: '#3776AB',
+ color: '#374151',
+ },
+ }}
+ >
+ {/* Full name on desktop, abbreviated on mobile */}
+
+ {impl.library_id}
+
+
+ {isCenter ? impl.library_id : (LIBRARY_ABBREV[impl.library_id] || impl.library_id)}
+
+ {score && isCenter && (
+
+ {Math.round(score)}
+
+ )}
+
+ );
+ })}
+
- return (
- onSelect(impl.library_id)}
- variant={isSelected ? 'filled' : 'outlined'}
- sx={{
- flexShrink: 0,
- fontFamily: '"MonoLisa", monospace',
- fontSize: '0.875rem',
- fontWeight: isSelected ? 600 : 400,
- bgcolor: isSelected ? '#3776AB' : 'transparent',
- color: isSelected ? '#fff' : '#6b7280',
- borderColor: isSelected ? '#3776AB' : '#d1d5db',
- '&:hover': {
- bgcolor: isSelected ? '#2c5f8a' : '#f3f4f6',
- borderColor: '#3776AB',
- },
- transition: 'all 0.15s ease',
- // Show quality score as subtle indicator
- '&::after': score ? {
- content: `"${Math.round(score)}"`,
- fontSize: '0.65rem',
- ml: 0.5,
- opacity: 0.7,
- } : undefined,
- }}
- />
- );
- })}
+ {/* Right Arrow */}
+
+
+
);
});
diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx
index e2779f8fec..b579104ed3 100644
--- a/app/src/components/SpecTabs.tsx
+++ b/app/src/components/SpecTabs.tsx
@@ -1,27 +1,43 @@
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useMemo } from 'react';
import Box from '@mui/material/Box';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
+import Chip from '@mui/material/Chip';
+import Collapse from '@mui/material/Collapse';
import CodeIcon from '@mui/icons-material/Code';
import DescriptionIcon from '@mui/icons-material/Description';
+import ImageIcon from '@mui/icons-material/Image';
import StarIcon from '@mui/icons-material/Star';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
-
-// Import Prism for syntax highlighting
-import Prism from 'prismjs';
-import 'prismjs/components/prism-python';
-import 'prismjs/themes/prism.css';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import ExpandLessIcon from '@mui/icons-material/ExpandLess';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface SpecTabsProps {
+ // Code tab
code: string | null;
+ // Specification tab
+ specId: string;
+ title: string;
description: string;
applications?: string[];
+ data?: string[];
notes?: string[];
+ tags?: Record;
+ created?: string;
+ // Implementation tab
+ imageDescription?: string;
+ strengths?: string[];
+ weaknesses?: string[];
+ // Quality tab
qualityScore: number | null;
+ criteriaChecklist?: Record;
+ // Common
libraryId: string;
onTrackEvent?: (name: string, props?: Record) => void;
}
@@ -34,27 +50,70 @@ interface TabPanelProps {
function TabPanel({ children, value, index }: TabPanelProps) {
return (
-
+
{value === index && children}
);
}
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SectionContent({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
export function SpecTabs({
code,
+ specId,
+ title,
description,
applications,
+ data,
notes,
+ tags,
+ created,
+ imageDescription,
+ strengths,
+ weaknesses,
qualityScore,
+ criteriaChecklist,
libraryId,
onTrackEvent,
}: SpecTabsProps) {
const [copied, setCopied] = useState(false);
- const [tabIndex, setTabIndex] = useState(0);
+ const [tabIndex, setTabIndex] = useState(0); // Code is default (index 0)
+ const [expandedCategories, setExpandedCategories] = useState>({});
+
+ const toggleCategory = (category: string) => {
+ setExpandedCategories((prev) => ({ ...prev, [category]: !prev[category] }));
+ };
const handleCopy = useCallback(async () => {
if (!code) return;
@@ -70,14 +129,50 @@ export function SpecTabs({
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setTabIndex(newValue);
- const tabNames = ['code', 'description', 'quality'];
+ const tabNames = ['code', 'specification', 'implementation', 'quality'];
onTrackEvent?.(`view_${tabNames[newValue]}`, { library: libraryId });
};
- // Highlight code with Prism
- const highlightedCode = code
- ? Prism.highlight(code, Prism.languages.python, 'python')
- : '';
+ // Memoize syntax-highlighted code
+ const highlightedCode = useMemo(() => {
+ if (!code) return null;
+ return (
+
+ {code}
+
+ );
+ }, [code]);
+
+ // Format date
+ const formatDate = (dateStr?: string) => {
+ if (!dateStr) return null;
+ try {
+ return new Date(dateStr).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ } catch {
+ return dateStr;
+ }
+ };
+
+ // Flatten tags for display
+ const allTags = useMemo(() => {
+ if (!tags) return [];
+ return Object.entries(tags).flatMap(([category, values]) =>
+ values.map((v) => ({ category, value: v }))
+ );
+ }, [tags]);
return (
@@ -101,20 +196,13 @@ export function SpecTabs({
},
}}
>
+ } iconPosition="start" label="Code" />
+ } iconPosition="start" label="Spec" />
+ } iconPosition="start" label="Impl" />
}
- iconPosition="start"
- label="Code"
- />
- }
+ icon={}
iconPosition="start"
- label="Info"
- />
- }
- iconPosition="start"
- label={qualityScore ? `${Math.round(qualityScore)}` : 'N/A'}
+ label={qualityScore ? `${Math.round(qualityScore)}` : 'Quality'}
/>
@@ -139,128 +227,308 @@ export function SpecTabs({
-
+ {highlightedCode}
- {/* Description Tab */}
+ {/* Specification Tab */}
-
- {description}
-
+
+ {/* ID */}
+
+ ID
+ {specId}
+
- {applications && applications.length > 0 && (
-
-
- Applications
-
-
- {applications.map((app, i) => (
-
- {app}
-
- ))}
-
+ {/* Title */}
+
+ Title
+ {title}
- )}
- {notes && notes.length > 0 && (
+ {/* Description */}
+
+ Description
+ {description}
+
+
+ {/* Applications */}
+ {applications && applications.length > 0 && (
+
+ Applications
+
+ {applications.map((app, i) => (
+
+ {app}
+
+ ))}
+
+
+ )}
+
+ {/* Data */}
+ {data && data.length > 0 && (
+
+ Data
+
+ {data.map((d, i) => (
+
+ {d}
+
+ ))}
+
+
+ )}
+
+ {/* Notes */}
+ {notes && notes.length > 0 && (
+
+ Notes
+
+ {notes.map((note, i) => (
+
+ {note}
+
+ ))}
+
+
+ )}
+
+ {/* Tags */}
+ {allTags.length > 0 && (
+
+ Tags
+
+ {allTags.map(({ category, value }, i) => (
+
+ ))}
+
+
+ )}
+
+ {/* Created */}
+ {created && (
+
+ Created
+ {formatDate(created)}
+
+ )}
+
+
+
+ {/* Implementation Tab */}
+
+
+ {/* Image Description */}
+ {imageDescription && (
+
+ AI Description
+ {imageDescription}
+
+ )}
+
+ {/* Strengths */}
+ {strengths && strengths.length > 0 && (
+
+ Strengths
+
+ {strengths.map((s, i) => (
+
+ {s}
+
+ ))}
+
+
+ )}
+
+ {/* Weaknesses */}
+ {weaknesses && weaknesses.length > 0 && (
+
+ Weaknesses
+
+ {weaknesses.map((w, i) => (
+
+ {w}
+
+ ))}
+
+
+ )}
+
+ {/* No data message */}
+ {!imageDescription && (!strengths || strengths.length === 0) && (!weaknesses || weaknesses.length === 0) && (
+
+ No implementation review data available.
+
+ )}
+
+
+
+ {/* Quality Tab */}
+
+
+ {/* Score */}
+ Quality Score
= 90 ? '#22c55e' : qualityScore && qualityScore >= 70 ? '#f59e0b' : '#ef4444',
}}
>
- Notes
+ {qualityScore ? `${Math.round(qualityScore)}/100` : 'N/A'}
-
- {notes.map((note, i) => (
-
- {note}
-
- ))}
-
- )}
-
- {/* Quality Tab */}
-
-
- {qualityScore && qualityScore >= 90 ? (
- <>This implementation scored {Math.round(qualityScore)}/100 in AI quality review.>
- ) : qualityScore ? (
- <>Quality score: {Math.round(qualityScore)}/100>
- ) : (
- 'Quality score not available.'
+ {/* Criteria Checklist */}
+ {criteriaChecklist && Object.keys(criteriaChecklist).length > 0 && (
+
+ Criteria Breakdown
+
+ {Object.entries(criteriaChecklist).map(([category, data]) => {
+ const catData = data as { score?: number; max?: number; items?: Array<{ id: string; name: string; score: number; max: number; passed: boolean; comment?: string }> };
+ const score = catData.score ?? 0;
+ const max = catData.max ?? 0;
+ const pct = max > 0 ? (score / max) * 100 : 0;
+ const items = catData.items || [];
+ const isExpanded = expandedCategories[category] ?? false;
+
+ return (
+
+ {/* Category header - clickable */}
+ items.length > 0 && toggleCategory(category)}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ mb: 0.5,
+ cursor: items.length > 0 ? 'pointer' : 'default',
+ '&:hover': items.length > 0 ? { opacity: 0.8 } : {},
+ }}
+ >
+
+ {items.length > 0 && (
+ isExpanded ? (
+
+ ) : (
+
+ )
+ )}
+
+ {category.replace(/_/g, ' ')}
+
+
+
+ {score}/{max}
+
+
+ {/* Progress bar */}
+
+ = 90 ? '#22c55e' : pct >= 70 ? '#f59e0b' : '#ef4444',
+ borderRadius: 2,
+ }}
+ />
+
+ {/* Expandable items */}
+
+
+ {items.map((item) => (
+
+
+
+
+
+ {item.name}
+
+
+
+ {item.score}/{item.max}
+
+
+ {item.comment && (
+
+ {item.comment}
+
+ )}
+
+ ))}
+
+
+
+ );
+ })}
+
+
)}
-
+
+ {/* No data message */}
+ {!qualityScore && (!criteriaChecklist || Object.keys(criteriaChecklist).length === 0) && (
+
+ No quality data available.
+
+ )}
+
);
diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx
index ddc03d9d36..8a69e56793 100644
--- a/app/src/pages/SpecPage.tsx
+++ b/app/src/pages/SpecPage.tsx
@@ -8,8 +8,6 @@ import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Skeleton from '@mui/material/Skeleton';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
-import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
-import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import DownloadIcon from '@mui/icons-material/Download';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
@@ -29,6 +27,12 @@ interface Implementation {
code: string | null;
generated_at?: string;
library_version?: string;
+ // Review fields
+ review_strengths?: string[];
+ review_weaknesses?: string[];
+ review_image_description?: string;
+ review_criteria_checklist?: Record;
+ review_verdict?: string;
}
interface SpecDetail {
@@ -39,6 +43,8 @@ interface SpecDetail {
data?: string[];
notes?: string[];
tags?: Record;
+ created?: string;
+ updated?: string;
implementations: Implementation[];
}
@@ -52,6 +58,7 @@ export function SpecPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [imageLoaded, setImageLoaded] = useState(false);
+ const [descExpanded, setDescExpanded] = useState(false);
// Fetch spec data
useEffect(() => {
@@ -101,23 +108,12 @@ export function SpecPage() {
fetchSpec();
}, [specId, urlLibrary, navigate]);
- // Get sorted implementations (alphabetical)
- const sortedImpls = useMemo(() => {
- if (!specData) return [];
- return [...specData.implementations].sort((a, b) => a.library_id.localeCompare(b.library_id));
- }, [specData]);
-
- // Get current implementation and index
+ // Get current implementation
const currentImpl = useMemo(() => {
if (!specData || !selectedLibrary) return null;
return specData.implementations.find((impl) => impl.library_id === selectedLibrary) || null;
}, [specData, selectedLibrary]);
- const currentIndex = useMemo(() => {
- if (!selectedLibrary) return 0;
- return sortedImpls.findIndex((impl) => impl.library_id === selectedLibrary);
- }, [sortedImpls, selectedLibrary]);
-
// Handle library switch
const handleLibrarySelect = useCallback(
(libraryId: string) => {
@@ -129,19 +125,6 @@ export function SpecPage() {
[specId, navigate, trackEvent]
);
- // Navigate to prev/next library
- const handlePrevLibrary = useCallback(() => {
- if (sortedImpls.length === 0) return;
- const newIndex = currentIndex <= 0 ? sortedImpls.length - 1 : currentIndex - 1;
- handleLibrarySelect(sortedImpls[newIndex].library_id);
- }, [sortedImpls, currentIndex, handleLibrarySelect]);
-
- const handleNextLibrary = useCallback(() => {
- if (sortedImpls.length === 0) return;
- const newIndex = currentIndex >= sortedImpls.length - 1 ? 0 : currentIndex + 1;
- handleLibrarySelect(sortedImpls[newIndex].library_id);
- }, [sortedImpls, currentIndex, handleLibrarySelect]);
-
// Handle download
const handleDownload = useCallback(() => {
if (!currentImpl?.preview_url) return;
@@ -221,6 +204,7 @@ export function SpecPage() {
textAlign: 'center',
fontFamily: '"MonoLisa", monospace',
fontWeight: 600,
+ fontSize: { xs: '1.25rem', sm: '1.5rem', md: '2rem' },
mb: 1,
color: '#1f2937',
}}
@@ -230,15 +214,23 @@ export function SpecPage() {
{/* Description */}
!descExpanded && setDescExpanded(true)}
sx={{
textAlign: 'center',
fontFamily: '"MonoLisa", monospace',
- fontSize: '0.9rem',
+ fontSize: { xs: '0.8rem', sm: '0.9rem' },
color: '#6b7280',
maxWidth: 700,
mx: 'auto',
mb: 2,
lineHeight: 1.6,
+ cursor: descExpanded ? 'default' : 'pointer',
+ ...(!descExpanded && {
+ display: '-webkit-box',
+ WebkitLineClamp: 5,
+ WebkitBoxOrient: 'vertical',
+ overflow: 'hidden',
+ }),
}}
>
{specData.description}
@@ -251,169 +243,137 @@ export function SpecPage() {
onSelect={handleLibrarySelect}
/>
- {/* Main Image with Navigation */}
+ {/* Main Image (full width) */}
- {/* Left Arrow */}
-
-
-
+ {!imageLoaded && (
+
+ )}
+ {currentImpl?.preview_url && (
+ setImageLoaded(true)}
+ sx={{
+ width: '100%',
+ height: '100%',
+ objectFit: 'contain',
+ display: imageLoaded ? 'block' : 'none',
+ }}
+ />
+ )}
- {/* Image Container */}
+ {/* Action Buttons (top-right) */}
- {!imageLoaded && (
-
- )}
- {currentImpl?.preview_url && (
- setImageLoaded(true)}
- sx={{
- width: '100%',
- height: '100%',
- objectFit: 'contain',
- display: imageLoaded ? 'block' : 'none',
- }}
- />
- )}
-
- {/* Library Badge (top-left) */}
-
-
+
- {selectedLibrary}
-
-
- {currentIndex + 1}/{sortedImpls.length}
-
-
-
- {/* Action Buttons (top-right) */}
-
-
+
+
+
+ {currentImpl?.preview_html && (
+
trackEvent('open_interactive', { spec: specId, library: selectedLibrary || undefined })}
sx={{
bgcolor: 'rgba(255,255,255,0.9)',
'&:hover': { bgcolor: '#fff' },
}}
size="small"
>
-
+
- {currentImpl?.preview_html && (
-
- trackEvent('open_interactive', { spec: specId, library: selectedLibrary || undefined })}
- sx={{
- bgcolor: 'rgba(255,255,255,0.9)',
- '&:hover': { bgcolor: '#fff' },
- }}
- size="small"
- >
-
-
-
- )}
-
+ )}
- {/* Right Arrow */}
-
-
-
+ {/* Implementation counter (hover) */}
+ {specData.implementations.length > 1 && (
+
+ {[...specData.implementations]
+ .sort((a, b) => a.library_id.localeCompare(b.library_id))
+ .findIndex((impl) => impl.library_id === selectedLibrary) + 1}
+ /{specData.implementations.length}
+
+ )}
{/* Tabs */}
From 5a6c327c74b6eed70eb5b0d9a1d653ef431c05cf Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Sun, 4 Jan 2026 00:57:58 +0100
Subject: [PATCH 04/14] feat: enhance layout and filter state management
- Introduced HomeState context for persistent state across navigation
- Updated Layout component to provide home state and scroll position management
- Refactored useFilterState to utilize persistent home state
- Improved HomePage to restore scroll position from home state
- Enhanced SpecPage layout responsiveness
---
app/src/components/Layout.tsx | 72 +++++++++++++++++++--
app/src/hooks/useFilterState.ts | 111 +++++++++++++++++++++++++++-----
app/src/pages/HomePage.tsx | 42 ++++++------
app/src/pages/SpecPage.tsx | 2 +-
4 files changed, 183 insertions(+), 44 deletions(-)
diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx
index 3ba69b2038..82e081b1db 100644
--- a/app/src/components/Layout.tsx
+++ b/app/src/components/Layout.tsx
@@ -1,10 +1,10 @@
-import { useState, useEffect, createContext, useContext } from 'react';
+import { useState, useEffect, createContext, useContext, useRef, useCallback } from 'react';
import { Outlet } from 'react-router-dom';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import { API_URL } from '../constants';
-import type { LibraryInfo, SpecInfo } from '../types';
+import type { LibraryInfo, SpecInfo, PlotImage, ActiveFilters, FilterCounts } from '../types';
interface AppData {
specsData: SpecInfo[];
@@ -12,7 +12,40 @@ interface AppData {
stats: { specs: number; plots: number; libraries: number } | null;
}
+// Persistent home state that survives navigation
+interface HomeState {
+ allImages: PlotImage[];
+ displayedImages: PlotImage[];
+ activeFilters: ActiveFilters;
+ filterCounts: FilterCounts | null;
+ globalCounts: FilterCounts | null;
+ orCounts: Record[];
+ hasMore: boolean;
+ scrollY: number;
+ initialized: boolean;
+}
+
+interface HomeStateContext {
+ homeState: HomeState;
+ homeStateRef: React.MutableRefObject;
+ setHomeState: React.Dispatch>;
+ saveScrollPosition: () => void;
+}
+
+const initialHomeState: HomeState = {
+ allImages: [],
+ displayedImages: [],
+ activeFilters: [],
+ filterCounts: null,
+ globalCounts: null,
+ orCounts: [],
+ hasMore: false,
+ scrollY: 0,
+ initialized: false,
+};
+
const AppDataContext = createContext(null);
+const HomeStateContext = createContext(null);
export function useAppData() {
const context = useContext(AppDataContext);
@@ -22,11 +55,34 @@ export function useAppData() {
return context;
}
+export function useHomeState() {
+ const context = useContext(HomeStateContext);
+ if (!context) {
+ throw new Error('useHomeState must be used within Layout');
+ }
+ return context;
+}
+
export function Layout() {
const [specsData, setSpecsData] = useState([]);
const [librariesData, setLibrariesData] = useState([]);
const [stats, setStats] = useState<{ specs: number; plots: number; libraries: number } | null>(null);
+ // Persistent home state (both ref for sync access and state for reactivity)
+ const [homeState, setHomeState] = useState(initialHomeState);
+ const homeStateRef = useRef(initialHomeState);
+
+ // Keep ref in sync with state
+ useEffect(() => {
+ homeStateRef.current = homeState;
+ }, [homeState]);
+
+ // Save scroll position synchronously to ref (called before navigation)
+ const saveScrollPosition = useCallback(() => {
+ homeStateRef.current = { ...homeStateRef.current, scrollY: window.scrollY };
+ setHomeState((prev) => ({ ...prev, scrollY: window.scrollY }));
+ }, []);
+
// Load shared data on mount
useEffect(() => {
const fetchData = async () => {
@@ -60,11 +116,13 @@ export function Layout() {
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/app/src/hooks/useFilterState.ts b/app/src/hooks/useFilterState.ts
index aad1a7fb03..53fc7337ec 100644
--- a/app/src/hooks/useFilterState.ts
+++ b/app/src/hooks/useFilterState.ts
@@ -1,7 +1,7 @@
/**
* Hook for managing filter state and URL synchronization.
*
- * Encapsulates all filter-related state and callbacks used in App.tsx.
+ * Uses persistent state from Layout context to survive navigation.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
@@ -9,19 +9,47 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import type { PlotImage, FilterCategory, ActiveFilters, FilterCounts } from '../types';
import { FILTER_CATEGORIES } from '../types';
import { API_URL, BATCH_SIZE } from '../constants';
+import { useHomeState } from '../components/Layout';
/**
- * Fisher-Yates shuffle algorithm.
+ * Seeded random number generator (mulberry32).
*/
-function shuffleArray(array: T[]): T[] {
+function seededRandom(seed: number): () => number {
+ return () => {
+ let t = (seed += 0x6d2b79f5);
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+/**
+ * Fisher-Yates shuffle algorithm with optional seed for deterministic results.
+ */
+function shuffleArray(array: T[], seed?: number): T[] {
const shuffled = [...array];
+ const random = seed !== undefined ? seededRandom(seed) : Math.random;
for (let i = shuffled.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
+ const j = Math.floor(random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
+/**
+ * Generate a hash from filter state for deterministic shuffle.
+ */
+function hashFilters(filters: ActiveFilters): number {
+ const str = JSON.stringify(filters);
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ hash = hash & hash;
+ }
+ return Math.abs(hash);
+}
+
/**
* Parse URL params into ActiveFilters.
* URL format: ?lib=matplotlib&lib=seaborn (AND) or ?lib=matplotlib,seaborn (OR within group)
@@ -106,19 +134,35 @@ export function useFilterState({
onTrackPageview,
onTrackEvent,
}: UseFilterStateOptions): UseFilterStateReturn {
- // Filter state - initialize from URL params immediately
- const [activeFilters, setActiveFilters] = useState(() => parseUrlFilters());
- const [filterCounts, setFilterCounts] = useState(null);
- const [globalCounts, setGlobalCounts] = useState(null);
- const [orCounts, setOrCounts] = useState[]>([]);
+ const { homeStateRef, setHomeState } = useHomeState();
+
+ // Initialize from persistent state (ref) or URL params (all using lazy initializers)
+ const [activeFilters, setActiveFilters] = useState(() =>
+ homeStateRef.current.initialized ? homeStateRef.current.activeFilters : parseUrlFilters()
+ );
+ const [filterCounts, setFilterCounts] = useState(() =>
+ homeStateRef.current.initialized ? homeStateRef.current.filterCounts : null
+ );
+ const [globalCounts, setGlobalCounts] = useState(() =>
+ homeStateRef.current.initialized ? homeStateRef.current.globalCounts : null
+ );
+ const [orCounts, setOrCounts] = useState[]>(() =>
+ homeStateRef.current.initialized ? homeStateRef.current.orCounts : []
+ );
- // Image state
- const [allImages, setAllImages] = useState([]);
- const [displayedImages, setDisplayedImages] = useState([]);
- const [hasMore, setHasMore] = useState(false);
+ // Image state - restore from persistent state if available
+ const [allImages, setAllImages] = useState(() =>
+ homeStateRef.current.initialized ? homeStateRef.current.allImages : []
+ );
+ const [displayedImages, setDisplayedImages] = useState(() =>
+ homeStateRef.current.initialized ? homeStateRef.current.displayedImages : []
+ );
+ const [hasMore, setHasMore] = useState(() =>
+ homeStateRef.current.initialized ? homeStateRef.current.hasMore : false
+ );
// UI state
- const [loading, setLoading] = useState(true);
+ const [loading, setLoading] = useState(() => !homeStateRef.current.initialized);
const [error, setError] = useState('');
const [randomAnimation, setRandomAnimation] = useState<{
index: number;
@@ -130,6 +174,23 @@ export function useFilterState({
const activeFiltersRef = useRef(activeFilters);
activeFiltersRef.current = activeFilters;
+ // Sync state changes back to persistent context
+ useEffect(() => {
+ if (allImages.length > 0 || displayedImages.length > 0) {
+ setHomeState((prev) => ({
+ ...prev,
+ allImages,
+ displayedImages,
+ activeFilters,
+ filterCounts,
+ globalCounts,
+ orCounts,
+ hasMore,
+ initialized: true,
+ }));
+ }
+ }, [allImages, displayedImages, activeFilters, filterCounts, globalCounts, orCounts, hasMore, setHomeState]);
+
// Add a new filter group (creates new chip - AND with other groups)
const handleAddFilter = useCallback((category: FilterCategory, value: string) => {
setActiveFilters((prev) => [...prev, { category, values: [value] }]);
@@ -234,8 +295,23 @@ export function useFilterState({
onTrackPageview();
}, [activeFilters, onTrackPageview]);
+ // Track if we should skip initial fetch (restored from persistent state)
+ const initializedRef = useRef(homeStateRef.current.initialized);
+ const filtersMatchRef = useRef(
+ homeStateRef.current.initialized && JSON.stringify(homeStateRef.current.activeFilters) === JSON.stringify(activeFilters)
+ );
+
// Load filtered images when filters change
useEffect(() => {
+ // Skip fetch on first mount if restored from persistent state with same filters
+ if (initializedRef.current && filtersMatchRef.current) {
+ initializedRef.current = false;
+ filtersMatchRef.current = false;
+ return;
+ }
+ initializedRef.current = false;
+ filtersMatchRef.current = false;
+
const abortController = new AbortController();
const fetchFilteredImages = async () => {
@@ -265,9 +341,12 @@ export function useFilterState({
setGlobalCounts(data.globalCounts || data.counts);
setOrCounts(data.orCounts || []);
- // Shuffle and set images
- const shuffled = shuffleArray(data.images || []);
+ // Shuffle with deterministic seed based on filters
+ const seed = hashFilters(activeFilters);
+ const shuffled = shuffleArray(data.images || [], seed);
setAllImages(shuffled);
+
+ // Initial display count
setDisplayedImages(shuffled.slice(0, BATCH_SIZE));
setHasMore(shuffled.length > BATCH_SIZE);
} catch (err) {
diff --git a/app/src/pages/HomePage.tsx b/app/src/pages/HomePage.tsx
index dad29a61c3..55da68c9e4 100644
--- a/app/src/pages/HomePage.tsx
+++ b/app/src/pages/HomePage.tsx
@@ -9,31 +9,18 @@ import type { PlotImage } from '../types';
import type { ImageSize } from '../constants';
import { useInfiniteScroll, useAnalytics, useFilterState, isFiltersEmpty } from '../hooks';
import { Header, Footer, FilterBar, ImagesGrid, FullscreenModal } from '../components';
-import { useAppData } from '../components/Layout';
+import { useAppData, useHomeState } from '../components/Layout';
export function HomePage() {
const navigate = useNavigate();
const { specsData, librariesData, stats } = useAppData();
+ const { homeStateRef, saveScrollPosition } = useHomeState();
- // Handle scroll restoration on back navigation
+ // Disable browser's automatic scroll restoration
useEffect(() => {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
-
- // Check for saved scroll position from back navigation
- const savedScrollY = sessionStorage.getItem('homeScrollY');
- if (savedScrollY) {
- // Delay scroll restoration to allow images to load
- const timer = setTimeout(() => {
- window.scrollTo(0, parseInt(savedScrollY, 10));
- // Clear the saved position
- sessionStorage.removeItem('homeScrollY');
- }, 150);
- return () => clearTimeout(timer);
- } else {
- window.scrollTo(0, 0);
- }
}, []);
// Custom hooks
@@ -69,6 +56,21 @@ export function HomePage() {
setHasMore,
});
+ // Restore scroll position from persistent state (ref for sync access)
+ const scrollRestoredRef = useRef(false);
+ useEffect(() => {
+ if (scrollRestoredRef.current) return;
+ const savedScrollY = homeStateRef.current.scrollY;
+ if (savedScrollY > 0 && displayedImages.length > 0) {
+ requestAnimationFrame(() => {
+ window.scrollTo(0, savedScrollY);
+ scrollRestoredRef.current = true;
+ });
+ } else if (displayedImages.length > 0) {
+ scrollRestoredRef.current = true;
+ }
+ }, [homeStateRef, displayedImages.length]);
+
// UI state
const [modalImage, setModalImage] = useState(null);
const [openImageTooltip, setOpenImageTooltip] = useState(null);
@@ -102,16 +104,16 @@ export function HomePage() {
document.activeElement.blur();
}
- // Save scroll position for back navigation
- sessionStorage.setItem('homeScrollY', String(window.scrollY));
+ // Save scroll position synchronously to ref before navigation
+ saveScrollPosition();
- // Navigate to spec page
+ // Navigate to spec page immediately
const specId = img.spec_id || '';
const library = img.library;
navigate(`/${specId}/${library}`);
trackEvent('navigate_to_spec', { spec: specId, library });
},
- [navigate, trackEvent]
+ [navigate, trackEvent, saveScrollPosition]
);
// Close tooltip when clicking anywhere
diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx
index 8a69e56793..8cbe0922ab 100644
--- a/app/src/pages/SpecPage.tsx
+++ b/app/src/pages/SpecPage.tsx
@@ -247,7 +247,7 @@ export function SpecPage() {
Date: Sun, 4 Jan 2026 22:08:39 +0100
Subject: [PATCH 05/14] feat: improve filter functionality and enhance image
fetching
- Added id and name attributes to filter input for better accessibility
- Adjusted layout responsiveness in SpecTabs component
- Updated score color logic in SpecTabs for clearer status indication
- Prevented double fetching of images in CatalogPage with useRef
- Implemented copy code functionality in SpecPage with user feedback
---
app/src/components/FilterBar.tsx | 2 ++
app/src/components/SpecTabs.tsx | 9 +++++++--
app/src/pages/CatalogPage.tsx | 23 ++++++++++++++++++++---
app/src/pages/SpecPage.tsx | 32 +++++++++++++++++++++++++++++++-
4 files changed, 60 insertions(+), 6 deletions(-)
diff --git a/app/src/components/FilterBar.tsx b/app/src/components/FilterBar.tsx
index 550a64709e..3cf04d84b8 100644
--- a/app/src/components/FilterBar.tsx
+++ b/app/src/components/FilterBar.tsx
@@ -437,6 +437,8 @@ export function FilterBar({
/>
{
diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx
index b579104ed3..fc12bf5237 100644
--- a/app/src/components/SpecTabs.tsx
+++ b/app/src/components/SpecTabs.tsx
@@ -175,7 +175,7 @@ export function SpecTabs({
}, [tags]);
return (
-
+
diff --git a/app/src/pages/CatalogPage.tsx b/app/src/pages/CatalogPage.tsx
index 62664dca10..9a1955cbb7 100644
--- a/app/src/pages/CatalogPage.tsx
+++ b/app/src/pages/CatalogPage.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
import Box from '@mui/material/Box';
@@ -27,22 +27,39 @@ export function CatalogPage() {
const [loading, setLoading] = useState(true);
const [rotationIndex, setRotationIndex] = useState>({});
+ // Ref to prevent double fetching in StrictMode
+ const fetchedRef = useRef(false);
+
// Fetch all images
useEffect(() => {
+ // Skip if already fetched (StrictMode double-render protection)
+ if (fetchedRef.current) return;
+ fetchedRef.current = true;
+
+ const abortController = new AbortController();
+
const fetchImages = async () => {
try {
- const res = await fetch(`${API_URL}/plots/filter`);
+ const res = await fetch(`${API_URL}/plots/filter`, {
+ signal: abortController.signal,
+ });
+ if (abortController.signal.aborted) return;
if (res.ok) {
const data = await res.json();
setAllImages(data.images || []);
}
} catch (err) {
+ if (abortController.signal.aborted) return;
console.error('Error fetching images:', err);
} finally {
- setLoading(false);
+ if (!abortController.signal.aborted) {
+ setLoading(false);
+ }
}
};
fetchImages();
+
+ return () => abortController.abort();
}, []);
// Group images by spec_id and merge with spec metadata
diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx
index 8cbe0922ab..c1752c6416 100644
--- a/app/src/pages/SpecPage.tsx
+++ b/app/src/pages/SpecPage.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useMemo, useCallback } from 'react';
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
import Box from '@mui/material/Box';
@@ -10,6 +10,8 @@ import Skeleton from '@mui/material/Skeleton';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DownloadIcon from '@mui/icons-material/Download';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import CheckIcon from '@mui/icons-material/Check';
import { API_URL } from '../constants';
import { useAnalytics } from '../hooks';
@@ -59,6 +61,7 @@ export function SpecPage() {
const [error, setError] = useState(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
+ const [codeCopied, setCodeCopied] = useState(false);
// Fetch spec data
useEffect(() => {
@@ -135,6 +138,19 @@ export function SpecPage() {
trackEvent('download_image', { spec: specId, library: selectedLibrary || undefined });
}, [currentImpl, specId, selectedLibrary, trackEvent]);
+ // Handle copy code
+ const handleCopyCode = useCallback(async () => {
+ if (!currentImpl?.code) return;
+ try {
+ await navigator.clipboard.writeText(currentImpl.code);
+ setCodeCopied(true);
+ trackEvent('copy_code', { spec: specId, library: selectedLibrary || undefined, method: 'image' });
+ setTimeout(() => setCodeCopied(false), 2000);
+ } catch (err) {
+ console.error('Copy failed:', err);
+ }
+ }, [currentImpl, specId, selectedLibrary, trackEvent]);
+
// Track page view
useEffect(() => {
if (specData && selectedLibrary) {
@@ -294,6 +310,20 @@ export function SpecPage() {
gap: 0.5,
}}
>
+ {currentImpl?.code && (
+
+
+ {codeCopied ? : }
+
+
+ )}
Date: Sun, 4 Jan 2026 22:51:07 +0100
Subject: [PATCH 06/14] feat: add interactive page for viewing specs
- Implement InteractivePage component for displaying interactive plots
- Update routing to include interactive view
- Modify SpecPage to link to the new interactive page
---
app/src/pages/InteractivePage.tsx | 227 ++++++++++++++++++++++++++++++
app/src/pages/SpecPage.tsx | 6 +-
app/src/router.tsx | 3 +
3 files changed, 232 insertions(+), 4 deletions(-)
create mode 100644 app/src/pages/InteractivePage.tsx
diff --git a/app/src/pages/InteractivePage.tsx b/app/src/pages/InteractivePage.tsx
new file mode 100644
index 0000000000..64f74c7df9
--- /dev/null
+++ b/app/src/pages/InteractivePage.tsx
@@ -0,0 +1,227 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { Helmet } from 'react-helmet-async';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+import Tooltip from '@mui/material/Tooltip';
+import CircularProgress from '@mui/material/CircularProgress';
+import CloseIcon from '@mui/icons-material/Close';
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+
+import { API_URL } from '../constants';
+
+// Default dimensions matching pyplots standard (4800x2700 at 300 DPI)
+const DEFAULT_WIDTH = 4800;
+const DEFAULT_HEIGHT = 2700;
+
+interface Implementation {
+ library_id: string;
+ preview_html?: string;
+}
+
+interface SpecDetail {
+ id: string;
+ title: string;
+ implementations: Implementation[];
+}
+
+export function InteractivePage() {
+ const { specId, library } = useParams();
+ const navigate = useNavigate();
+ const containerRef = useRef(null);
+
+ const [htmlUrl, setHtmlUrl] = useState(null);
+ const [title, setTitle] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [scale, setScale] = useState(1);
+
+ // Calculate scale to fit container
+ const updateScale = useCallback(() => {
+ if (containerRef.current) {
+ const containerWidth = containerRef.current.clientWidth;
+ const containerHeight = containerRef.current.clientHeight;
+ const scaleX = containerWidth / DEFAULT_WIDTH;
+ const scaleY = containerHeight / DEFAULT_HEIGHT;
+ setScale(Math.min(scaleX, scaleY));
+ }
+ }, []);
+
+ // Update scale on mount, resize, and when content loads
+ useEffect(() => {
+ const timer = setTimeout(updateScale, 100);
+ window.addEventListener('resize', updateScale);
+ return () => {
+ clearTimeout(timer);
+ window.removeEventListener('resize', updateScale);
+ };
+ }, [updateScale, loading, htmlUrl]);
+
+ // Fetch spec data to get preview_html URL
+ useEffect(() => {
+ if (!specId || !library) return;
+
+ const fetchSpec = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const res = await fetch(`${API_URL}/specs/${specId}`);
+ if (!res.ok) {
+ setError('Failed to load interactive plot');
+ return;
+ }
+
+ const data: SpecDetail = await res.json();
+ setTitle(data.title);
+
+ const impl = data.implementations.find((i) => i.library_id === library);
+ if (impl?.preview_html) {
+ setHtmlUrl(impl.preview_html);
+ } else {
+ setError('No interactive version available for this library');
+ }
+ } catch (err) {
+ console.error('Error fetching spec:', err);
+ setError('Failed to load interactive plot');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchSpec();
+ }, [specId, library]);
+
+ const handleClose = () => {
+ navigate(`/${specId}/${library}`);
+ };
+
+ const handleOpenExternal = () => {
+ if (htmlUrl) {
+ window.open(htmlUrl, '_blank', 'noopener,noreferrer');
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !htmlUrl) {
+ return (
+
+ {error || 'Interactive plot not available'}
+
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+ {`${title} - ${library} (Interactive) | pyplots.ai`}
+
+
+
+ {/* Top bar with controls */}
+
+
+ {specId} · {library}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Fullscreen iframe - scaled to fit container */}
+
+
+
+
+ >
+ );
+}
diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx
index c1752c6416..6e29e5ba0c 100644
--- a/app/src/pages/SpecPage.tsx
+++ b/app/src/pages/SpecPage.tsx
@@ -339,10 +339,8 @@ export function SpecPage() {
{currentImpl?.preview_html && (
trackEvent('open_interactive', { spec: specId, library: selectedLibrary || undefined })}
sx={{
bgcolor: 'rgba(255,255,255,0.9)',
diff --git a/app/src/router.tsx b/app/src/router.tsx
index 921d30cc60..c1e529911e 100644
--- a/app/src/router.tsx
+++ b/app/src/router.tsx
@@ -4,6 +4,7 @@ import { Layout } from './components/Layout';
import { HomePage } from './pages/HomePage';
import { SpecPage } from './pages/SpecPage';
import { CatalogPage } from './pages/CatalogPage';
+import { InteractivePage } from './pages/InteractivePage';
const router = createBrowserRouter([
{
@@ -16,6 +17,8 @@ const router = createBrowserRouter([
{ path: ':specId/:library', element: },
],
},
+ // Fullscreen interactive view (outside Layout)
+ { path: 'interactive/:specId/:library', element: },
]);
export function AppRouter() {
From ddc7f73f2263aab90fcc3a1f54f53a9f8b21b2d9 Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Sun, 4 Jan 2026 22:57:42 +0100
Subject: [PATCH 07/14] feat: add HTML proxy endpoint and update interactive
page for size reporting
- Introduced a new proxy endpoint to fetch HTML and inject size reporting script
- Updated InteractivePage to use dynamic dimensions based on content size
- Modified initialization of dimensions for better responsiveness
- Included proxy router in the application
---
api/main.py | 2 +
api/routers/__init__.py | 2 +
api/routers/proxy.py | 104 ++++++++++++++++++++++++++++++
app/src/pages/InteractivePage.tsx | 57 +++++++++++-----
4 files changed, 149 insertions(+), 16 deletions(-)
create mode 100644 api/routers/proxy.py
diff --git a/api/main.py b/api/main.py
index 15d1eeb8ce..01826ead78 100644
--- a/api/main.py
+++ b/api/main.py
@@ -27,6 +27,7 @@
health_router,
libraries_router,
plots_router,
+ proxy_router,
seo_router,
specs_router,
stats_router,
@@ -128,6 +129,7 @@ async def add_cache_headers(request: Request, call_next):
app.include_router(plots_router)
app.include_router(download_router)
app.include_router(seo_router)
+app.include_router(proxy_router)
if __name__ == "__main__":
diff --git a/api/routers/__init__.py b/api/routers/__init__.py
index aa35c53263..e30046154a 100644
--- a/api/routers/__init__.py
+++ b/api/routers/__init__.py
@@ -4,6 +4,7 @@
from api.routers.health import router as health_router
from api.routers.libraries import router as libraries_router
from api.routers.plots import router as plots_router
+from api.routers.proxy import router as proxy_router
from api.routers.seo import router as seo_router
from api.routers.specs import router as specs_router
from api.routers.stats import router as stats_router
@@ -14,6 +15,7 @@
"health_router",
"libraries_router",
"plots_router",
+ "proxy_router",
"seo_router",
"specs_router",
"stats_router",
diff --git a/api/routers/proxy.py b/api/routers/proxy.py
new file mode 100644
index 0000000000..78e5d4bf4d
--- /dev/null
+++ b/api/routers/proxy.py
@@ -0,0 +1,104 @@
+"""HTML proxy endpoint for interactive plots with size reporting."""
+
+import httpx
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import HTMLResponse
+
+router = APIRouter(tags=["proxy"])
+
+# Script injected to report content size to parent window
+SIZE_REPORTER_SCRIPT = """
+
+"""
+
+# Allowed GCS bucket for security
+ALLOWED_HOST = "storage.googleapis.com"
+ALLOWED_BUCKET = "pyplots-images"
+
+
+@router.get("/proxy/html", response_class=HTMLResponse)
+async def proxy_html(url: str):
+ """
+ Proxy an HTML file and inject size reporting script.
+
+ This endpoint fetches HTML from GCS, injects a script that reports
+ the content's actual dimensions via postMessage, and returns the
+ modified HTML. This allows the frontend to dynamically scale the
+ iframe based on actual content size.
+
+ Args:
+ url: The GCS URL to fetch (must be from allowed bucket)
+
+ Returns:
+ Modified HTML with size reporting script injected
+ """
+ # Security: Only allow URLs from our GCS bucket
+ if not url.startswith(f"https://{ALLOWED_HOST}/{ALLOWED_BUCKET}/"):
+ raise HTTPException(
+ status_code=400,
+ detail=f"Only URLs from {ALLOWED_HOST}/{ALLOWED_BUCKET} are allowed",
+ )
+
+ # Fetch the HTML
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ try:
+ response = await client.get(url)
+ response.raise_for_status()
+ except httpx.HTTPStatusError as e:
+ raise HTTPException(status_code=e.response.status_code, detail="Failed to fetch HTML")
+ except httpx.RequestError:
+ raise HTTPException(status_code=502, detail="Failed to connect to storage")
+
+ html_content = response.text
+
+ # Inject the size reporter script before