From 1035369ea9528dcb9ca687e794c925ebde475194 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:01:13 +0200 Subject: [PATCH 1/6] =?UTF-8?q?style:=20visual=20redesign=20=E2=80=94=20la?= =?UTF-8?q?rger=20fonts,=20better=20contrast,=20lightbox,=20cleaner=20navi?= =?UTF-8?q?gation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Theme tokens: font scale +15%, semantic color tokens for WCAG AA contrast - MUI theme: primary color aligned to Python blue, global tooltip style - Navigation: remove "catalog" from breadcrumb, add catalog link in rightAction + footer - Lightbox: new ImageLightbox component replaces image-click-to-go-back with zoom - SpecPage: keyboard arrow keys for library switching, "< all implementations" link - SpecOverview: responsive grid fix (1/2/3 cols instead of fixed 3) - RelatedSpecs: single-row auto-fit grid, abbreviated library names, tooltips - Tags: larger chips (24px), tag count tooltips from globalCounts API - Cards: focus-visible only, ">>> copied" overlay, blue hover on action buttons - Toolbar: larger icons (1.4rem), better contrast - Layout: ultrawide support (max-width 2200px, xl padding) - All components migrated from hardcoded values to theme tokens - New style guide: docs/reference/style-guide.md Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/Breadcrumb.tsx | 7 +- app/src/components/FilterBar.tsx | 49 ++-- app/src/components/Footer.tsx | 38 +++- app/src/components/Header.tsx | 5 +- app/src/components/ImageCard.tsx | 38 +++- app/src/components/ImageLightbox.tsx | 312 ++++++++++++++++++++++++++ app/src/components/Layout.tsx | 2 +- app/src/components/LibraryPills.tsx | 11 +- app/src/components/PlotOfTheDay.tsx | 13 +- app/src/components/RelatedSpecs.tsx | 38 +++- app/src/components/SpecDetailView.tsx | 14 +- app/src/components/SpecOverview.tsx | 25 ++- app/src/components/SpecTabs.tsx | 53 ++++- app/src/components/ToolbarActions.tsx | 21 +- app/src/main.tsx | 26 ++- app/src/pages/CatalogPage.tsx | 17 +- app/src/pages/SpecPage.tsx | 147 +++++++++--- app/src/theme/index.ts | 21 +- docs/reference/style-guide.md | 210 +++++++++++++++++ 19 files changed, 886 insertions(+), 161 deletions(-) create mode 100644 app/src/components/ImageLightbox.tsx create mode 100644 docs/reference/style-guide.md diff --git a/app/src/components/Breadcrumb.tsx b/app/src/components/Breadcrumb.tsx index 48a418952b..1442b32345 100644 --- a/app/src/components/Breadcrumb.tsx +++ b/app/src/components/Breadcrumb.tsx @@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'; import Box from '@mui/material/Box'; import type { SxProps, Theme } from '@mui/material/styles'; +import { fontSize, semanticColors } from '../theme'; export interface BreadcrumbItem { label: string; @@ -49,7 +50,7 @@ export function Breadcrumb({ items, rightAction, sx }: BreadcrumbProps) { bgcolor: '#f3f4f6', borderBottom: '1px solid #e5e7eb', fontFamily: '"MonoLisa", monospace', - fontSize: '0.85rem', + fontSize: fontSize.base, ...sx, }} > @@ -58,7 +59,7 @@ export function Breadcrumb({ items, rightAction, sx }: BreadcrumbProps) { {items.map((item, index) => ( {index > 0 && ( - + )} @@ -75,7 +76,7 @@ export function Breadcrumb({ items, rightAction, sx }: BreadcrumbProps) { {item.label} ) : ( - + {item.label} )} diff --git a/app/src/components/FilterBar.tsx b/app/src/components/FilterBar.tsx index 53d7d71905..2457759a7f 100644 --- a/app/src/components/FilterBar.tsx +++ b/app/src/components/FilterBar.tsx @@ -19,6 +19,7 @@ import { FILTER_LABELS, FILTER_TOOLTIPS, FILTER_CATEGORIES } from '../types'; import type { ImageSize } from '../constants'; import { getAvailableValues, getAvailableValuesForGroup, getSearchResults, type SearchResult } from '../utils'; import { ToolbarActions } from './ToolbarActions'; +import { fontSize, semanticColors } from '../theme'; interface FilterBarProps { activeFilters: ActiveFilters; @@ -345,8 +346,8 @@ export function FilterBar({ position: 'absolute', left: 0, fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - fontSize: '0.75rem', - color: '#9ca3af', + fontSize: fontSize.sm, + color: semanticColors.mutedText, whiteSpace: 'nowrap', }} > @@ -381,7 +382,7 @@ export function FilterBar({ deleteIcon={} sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - fontSize: '0.85rem', + fontSize: fontSize.base, height: 32, bgcolor: '#f3f4f6', border: '1px solid #3776AB', @@ -492,11 +493,11 @@ export function FilterBar({ opacity: isSearchExpanded ? 1 : 0, transition: 'all 0.2s ease', fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - fontSize: '0.85rem', + fontSize: fontSize.base, '& input': { padding: 0, '&::placeholder': { - color: '#9ca3af', + color: semanticColors.mutedText, opacity: 1, }, }, @@ -511,7 +512,7 @@ export function FilterBar({ }} sx={{ color: '#9ca3af', - fontSize: '0.9rem', + fontSize: fontSize.lg, cursor: 'pointer', '&:hover': { color: '#6b7280' }, }} @@ -535,8 +536,8 @@ export function FilterBar({ @@ -603,12 +604,12 @@ export function FilterBar({ secondary={`${availableVals.length} options`} primaryTypographyProps={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - fontSize: '0.9rem', + fontSize: fontSize.lg, }} secondaryTypographyProps={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - fontSize: '0.75rem', - color: '#9ca3af', + fontSize: fontSize.sm, + color: semanticColors.mutedText, }} /> @@ -665,19 +666,19 @@ export function FilterBar({ secondary={!selectedCategory ? FILTER_LABELS[category] : undefined} primaryTypographyProps={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - fontSize: '0.85rem', + fontSize: fontSize.base, }} secondaryTypographyProps={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - fontSize: '0.7rem', - color: '#9ca3af', + fontSize: fontSize.xs, + color: semanticColors.mutedText, }} /> @@ -705,8 +706,8 @@ export function FilterBar({ no matches @@ -764,8 +765,8 @@ export function FilterBar({ sx={{ px: 2, py: 0.5, - fontSize: '0.7rem', - color: '#9ca3af', + fontSize: fontSize.xs, + color: semanticColors.mutedText, fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', textTransform: 'uppercase', }} @@ -779,8 +780,8 @@ export function FilterBar({ sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', py: 0.5 }} > - {value} - ({count}) + {value} + ({count}) )), , diff --git a/app/src/components/Footer.tsx b/app/src/components/Footer.tsx index 1e327ac873..34029058a9 100644 --- a/app/src/components/Footer.tsx +++ b/app/src/components/Footer.tsx @@ -2,6 +2,7 @@ import Box from '@mui/material/Box'; import Link from '@mui/material/Link'; import { Link as RouterLink } from 'react-router-dom'; import { GITHUB_URL } from '../constants'; +import { fontSize, semanticColors } from '../theme'; interface FooterProps { onTrackEvent?: (name: string, props?: Record) => void; @@ -18,9 +19,9 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr justifyContent: 'center', alignItems: 'center', gap: 1, - fontSize: '0.8rem', + fontSize: fontSize.md, fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - color: '#9ca3af', + color: semanticColors.mutedText, }} > onTrackEvent?.('external_link', { destination: 'github', spec: selectedSpec, library: selectedLibrary })} sx={{ - color: '#9ca3af', + color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#6b7280' }, + '&:hover': { color: '#374151' }, }} > github · + onTrackEvent?.('internal_link', { destination: 'catalog', spec: selectedSpec, library: selectedLibrary })} + sx={{ + color: semanticColors.mutedText, + textDecoration: 'none', + '&:hover': { color: '#374151' }, + }} + > + catalog + + · onTrackEvent?.('internal_link', { destination: 'stats', spec: selectedSpec, library: selectedLibrary })} sx={{ - color: '#9ca3af', + color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#6b7280' }, + '&:hover': { color: '#374151' }, }} > stats @@ -57,9 +71,9 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr rel="noopener noreferrer" onClick={() => onTrackEvent?.('external_link', { destination: 'linkedin', spec: selectedSpec, library: selectedLibrary })} sx={{ - color: '#9ca3af', + color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#6b7280' }, + '&:hover': { color: '#374151' }, }} > markus neusinger @@ -71,9 +85,9 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr to="/mcp" onClick={() => onTrackEvent?.('internal_link', { destination: 'mcp', spec: selectedSpec, library: selectedLibrary })} sx={{ - color: '#9ca3af', + color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#6b7280' }, + '&:hover': { color: '#374151' }, }} > mcp @@ -84,9 +98,9 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr to="/legal" onClick={() => onTrackEvent?.('internal_link', { destination: 'legal', spec: selectedSpec, library: selectedLibrary })} sx={{ - color: '#9ca3af', + color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#6b7280' }, + '&:hover': { color: '#374151' }, }} > legal diff --git a/app/src/components/Header.tsx b/app/src/components/Header.tsx index fbf5e74f1b..ca0aa2eaf2 100644 --- a/app/src/components/Header.tsx +++ b/app/src/components/Header.tsx @@ -7,6 +7,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 { semanticColors } from '../theme'; interface HeaderProps { stats?: { specs: number; plots: number; libraries: number } | null; @@ -165,7 +166,7 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) { mx: 'auto', lineHeight: 1.8, fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - color: '#6b7280', + color: semanticColors.subtleText, fontSize: { xs: '0.875rem', md: '1rem' }, }} > @@ -180,7 +181,7 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) { lineHeight: 1.8, fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', color: '#374151', - fontSize: { xs: '0.925rem', md: '1.05rem' }, + fontSize: { xs: '1rem', md: '1.125rem' }, fontWeight: 500, }} > diff --git a/app/src/components/ImageCard.tsx b/app/src/components/ImageCard.tsx index d5b2e9df21..eb5f45980b 100644 --- a/app/src/components/ImageCard.tsx +++ b/app/src/components/ImageCard.tsx @@ -15,6 +15,7 @@ import type { PlotImage } from '../types'; import { BATCH_SIZE, type ImageSize } from '../constants'; import { useCodeFetch } from '../hooks'; import { buildSrcSet, getResponsiveSizes, getFallbackSrc } from '../utils/responsiveImage'; +import { fontSize, semanticColors } from '../theme'; // Library abbreviations for compact mode const LIBRARY_ABBR: Record = { @@ -61,7 +62,7 @@ export const ImageCard = memo(function ImageCard({ const theme = useTheme(); const isXs = useMediaQuery(theme.breakpoints.down('sm')); // < 600px - const labelFontSize = imageSize === 'compact' ? '0.65rem' : '0.8rem'; + const labelFontSize = imageSize === 'compact' ? fontSize.xs : fontSize.md; const labelLetterSpacing = isXs ? '-0.03em' : 'normal'; const { fetchCode } = useCodeFetch(); const [copyState, setCopyState] = useState<'idle' | 'loading' | 'copied'>('idle'); @@ -136,10 +137,14 @@ export const ImageCard = memo(function ImageCard({ position: 'relative', borderRadius: 3, overflow: 'hidden', - border: '2px solid rgba(55, 118, 171, 0.2)', + border: '2px solid rgba(55, 118, 171, 0.3)', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', transition: 'all 0.3s ease', cursor: 'pointer', + outline: 'none', + '&:focus-visible': { + border: '2px solid rgba(55, 118, 171, 0.6)', + }, '&:hover': { border: '2px solid rgba(55, 118, 171, 0.4)', boxShadow: '0 8px 30px rgba(0,0,0,0.15)', @@ -185,6 +190,26 @@ export const ImageCard = memo(function ImageCard({ }} /> + {/* Copied confirmation */} + {copyState === 'copied' && ( + + {'>>> copied'} + + )} {/* Copy button - appears on hover */} {copyState === 'loading' ? ( @@ -243,7 +269,7 @@ export const ImageCard = memo(function ImageCard({ letterSpacing: labelLetterSpacing, fontWeight: 600, fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - color: isSpecTooltipOpen ? '#3776AB' : '#9ca3af', + color: isSpecTooltipOpen ? '#3776AB' : semanticColors.labelText, textTransform: 'lowercase', cursor: 'pointer', '&:hover': { @@ -313,7 +339,7 @@ export const ImageCard = memo(function ImageCard({ letterSpacing: labelLetterSpacing, fontWeight: 600, fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - color: isLibTooltipOpen ? '#3776AB' : '#9ca3af', + color: isLibTooltipOpen ? '#3776AB' : semanticColors.labelText, textTransform: 'lowercase', cursor: 'pointer', '&:hover': { diff --git a/app/src/components/ImageLightbox.tsx b/app/src/components/ImageLightbox.tsx new file mode 100644 index 0000000000..f8e5394502 --- /dev/null +++ b/app/src/components/ImageLightbox.tsx @@ -0,0 +1,312 @@ +/** + * ImageLightbox component - Full-resolution plot image viewer. + * + * MUI Modal-based lightbox with library switching, keyboard navigation, + * and action buttons (copy code, download, open interactive). + */ + +import { useEffect, useMemo, useCallback } from 'react'; +import Modal from '@mui/material/Modal'; +import Fade from '@mui/material/Fade'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import CloseIcon from '@mui/icons-material/Close'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; +import DownloadIcon from '@mui/icons-material/Download'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; + +import type { Implementation } from '../types'; +import { fontSize } from '../theme'; +import { buildDetailSrcSet, DETAIL_SIZES } from '../utils/responsiveImage'; + +interface ImageLightboxProps { + open: boolean; + specId: string; + specTitle: string; + currentImpl: Implementation | null; + implementations: Implementation[]; + codeCopied: string | null; + onClose: () => void; + onSelectLibrary: (libraryId: string) => void; + onCopyCode: (impl: Implementation) => void; + onDownload: (impl: Implementation) => void; + onTrackEvent: (event: string, props?: Record) => void; +} + +export function ImageLightbox({ + open, + specId, + specTitle, + currentImpl, + implementations, + codeCopied, + onClose, + onSelectLibrary, + onCopyCode, + onDownload, + onTrackEvent, +}: ImageLightboxProps) { + // Sort implementations alphabetically for consistent ordering + const sortedImpls = useMemo( + () => [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)), + [implementations], + ); + + const currentIndex = useMemo(() => { + if (!currentImpl) return 0; + const idx = sortedImpls.findIndex((impl) => impl.library_id === currentImpl.library_id); + return idx >= 0 ? idx : 0; + }, [sortedImpls, currentImpl]); + + const navigatePrev = useCallback(() => { + if (sortedImpls.length < 2) return; + const prev = (currentIndex - 1 + sortedImpls.length) % sortedImpls.length; + onSelectLibrary(sortedImpls[prev].library_id); + }, [sortedImpls, currentIndex, onSelectLibrary]); + + const navigateNext = useCallback(() => { + if (sortedImpls.length < 2) return; + const next = (currentIndex + 1) % sortedImpls.length; + onSelectLibrary(sortedImpls[next].library_id); + }, [sortedImpls, currentIndex, onSelectLibrary]); + + // Keyboard navigation: left/right arrows switch libraries + useEffect(() => { + if (!open) return; + + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return; + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + navigatePrev(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + navigateNext(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [open, navigatePrev, navigateNext]); + + if (!currentImpl) return null; + + return ( + + + + {/* Close button (top-right) */} + + + + + {/* Left arrow */} + {sortedImpls.length > 1 && ( + + + + )} + + {/* Right arrow */} + {sortedImpls.length > 1 && ( + + + + )} + + {/* Full-resolution image */} + {currentImpl.preview_url && ( + + + + ) => { + const target = e.target as HTMLImageElement; + if (!target.dataset.fallback) { + target.dataset.fallback = '1'; + target.closest('picture')?.querySelectorAll('source').forEach((s) => s.remove()); + target.removeAttribute('srcset'); + target.src = currentImpl.preview_url!; + } + }} + /> + + )} + + {/* Bottom bar: library name + action buttons */} + + + {currentImpl.library_id} + + + + {currentImpl.code && ( + + onCopyCode(currentImpl)} + aria-label="Copy code" + size="small" + sx={{ + color: '#fff', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, + }} + > + {codeCopied === currentImpl.library_id ? ( + + ) : ( + + )} + + + )} + + + onDownload(currentImpl)} + aria-label="Download PNG" + size="small" + sx={{ + color: '#fff', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, + }} + > + + + + + {currentImpl.preview_html && ( + + + onTrackEvent('open_interactive', { + spec: specId, + library: currentImpl.library_id, + }) + } + aria-label="Open interactive" + size="small" + sx={{ + color: '#fff', + '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, + }} + > + + + + )} + + + + + + ); +} diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index 9d1e587f3c..cd9a9a3990 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -82,7 +82,7 @@ export function Layout() { - + diff --git a/app/src/components/LibraryPills.tsx b/app/src/components/LibraryPills.tsx index 5493dd140f..843972250b 100644 --- a/app/src/components/LibraryPills.tsx +++ b/app/src/components/LibraryPills.tsx @@ -3,6 +3,7 @@ import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { fontSize, semanticColors } from '../theme'; // Library abbreviations (same as filter display) const LIBRARY_ABBREV: Record = { @@ -97,7 +98,7 @@ export const LibraryPills = memo(function LibraryPills({ aria-label="Previous library" size="small" sx={{ - color: '#9ca3af', + color: semanticColors.mutedText, '&:hover': { color: '#3776AB', bgcolor: '#f3f4f6' }, }} > @@ -127,11 +128,11 @@ export const LibraryPills = memo(function LibraryPills({ py: 0.5, borderRadius: 2, fontFamily: '"MonoLisa", monospace', - fontSize: '0.85rem', + fontSize: fontSize.base, fontWeight: isCenter ? 600 : 400, bgcolor: '#f3f4f6', border: isCenter ? '1px solid #3776AB' : '1px solid transparent', - color: isCenter ? '#374151' : '#9ca3af', + color: isCenter ? '#374151' : semanticColors.mutedText, cursor: 'pointer', transition: 'all 0.2s ease', whiteSpace: 'nowrap', @@ -154,7 +155,7 @@ export const LibraryPills = memo(function LibraryPills({ component="span" sx={{ ml: 0.5, - fontSize: '0.7rem', + fontSize: fontSize.xs, color: '#6b7280', }} > @@ -172,7 +173,7 @@ export const LibraryPills = memo(function LibraryPills({ aria-label="Next library" size="small" sx={{ - color: '#9ca3af', + color: semanticColors.mutedText, '&:hover': { color: '#3776AB', bgcolor: '#f3f4f6' }, }} > diff --git a/app/src/components/PlotOfTheDay.tsx b/app/src/components/PlotOfTheDay.tsx index f489edac1c..fdb72adce3 100644 --- a/app/src/components/PlotOfTheDay.tsx +++ b/app/src/components/PlotOfTheDay.tsx @@ -7,6 +7,7 @@ import IconButton from '@mui/material/IconButton'; import CloseIcon from '@mui/icons-material/Close'; import { API_URL } from '../constants'; +import { fontSize, semanticColors } from '../theme'; import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; interface PlotOfTheDayData { @@ -65,7 +66,7 @@ export function PlotOfTheDay() { '&:hover': { color: '#9ca3af' }, }} > - + {/* Preview image */} {data.preview_url && ( @@ -91,10 +92,10 @@ export function PlotOfTheDay() { {/* Info */} - + plot of the day - + {data.date} @@ -102,18 +103,18 @@ export function PlotOfTheDay() { - + {data.spec_title} - + {data.library_name} · {data.quality_score}/100 {data.image_description && ( diff --git a/app/src/components/RelatedSpecs.tsx b/app/src/components/RelatedSpecs.tsx index 30e7c744eb..9a8bc88d70 100644 --- a/app/src/components/RelatedSpecs.tsx +++ b/app/src/components/RelatedSpecs.tsx @@ -6,6 +6,7 @@ import Link from '@mui/material/Link'; import { API_URL } from '../constants'; import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; +import { fontSize, semanticColors } from '../theme'; interface RelatedSpec { id: string; @@ -18,6 +19,18 @@ interface RelatedSpec { const mono = '"MonoLisa", "MonoLisa Fallback", monospace'; +const LIB_ABBREV: Record = { + matplotlib: 'mpl', + seaborn: 'sns', + plotly: 'ply', + bokeh: 'bok', + altair: 'alt', + plotnine: 'p9', + pygal: 'pyg', + highcharts: 'hc', + letsplot: 'lp', +}; + // 6 columns max at md+, ~160px each → 400w is plenty const SIZES = '(max-width: 599px) 50vw, (max-width: 899px) 33vw, 17vw'; @@ -49,13 +62,16 @@ export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: Re return ( - + {mode === 'full' ? 'similar implementations' : 'similar specifications'} {related.map(spec => ( no preview )} - - + + {spec.title} - - - {spec.shared_tags.length} tags in common + + + {spec.shared_tags.length} common {mode === 'full' && spec.library_id && ( - - {spec.library_id} + + {LIB_ABBREV[spec.library_id] || spec.library_id} )} diff --git a/app/src/components/SpecDetailView.tsx b/app/src/components/SpecDetailView.tsx index 62e491f321..61a202e3de 100644 --- a/app/src/components/SpecDetailView.tsx +++ b/app/src/components/SpecDetailView.tsx @@ -26,7 +26,7 @@ interface SpecDetailViewProps { imageLoaded: boolean; codeCopied: string | null; onImageLoad: () => void; - onImageClick: () => void; + onImageZoom: () => void; onCopyCode: (impl: Implementation) => void; onDownload: (impl: Implementation) => void; onTrackEvent: (event: string, props?: Record) => void; @@ -41,7 +41,7 @@ export function SpecDetailView({ imageLoaded, codeCopied, onImageLoad, - onImageClick, + onImageZoom, onCopyCode, onDownload, onTrackEvent, @@ -58,7 +58,7 @@ export function SpecDetailView({ }} > @@ -161,7 +161,7 @@ export function SpecDetailView({ aria-label="Download PNG" sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, + '&:hover': { bgcolor: '#fff', color: '#3776AB' }, }} size="small" > @@ -181,7 +181,7 @@ export function SpecDetailView({ }} sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, + '&:hover': { bgcolor: '#fff', color: '#3776AB' }, }} size="small" > diff --git a/app/src/components/SpecOverview.tsx b/app/src/components/SpecOverview.tsx index 56ee219ed7..287eb491ea 100644 --- a/app/src/components/SpecOverview.tsx +++ b/app/src/components/SpecOverview.tsx @@ -19,6 +19,7 @@ import CheckIcon from '@mui/icons-material/Check'; import type { Implementation } from '../types'; import { buildSrcSet, OVERVIEW_SIZES } from '../utils/responsiveImage'; +import { fontSize, semanticColors } from '../theme'; interface LibraryMeta { id: string; @@ -60,11 +61,11 @@ export function SpecOverview({ return ( @@ -201,7 +202,7 @@ function ImplementationCard({ aria-label="Copy code" sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, + '&:hover': { bgcolor: '#fff', color: '#3776AB' }, }} size="small" > @@ -238,7 +239,7 @@ function ImplementationCard({ }} sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, + '&:hover': { bgcolor: '#fff', color: '#3776AB' }, }} size="small" > @@ -264,7 +265,7 @@ function ImplementationCard({ - + {libMeta?.description || 'No description available'} {libMeta?.documentation_url && ( @@ -277,7 +278,7 @@ function ImplementationCard({ display: 'inline-flex', alignItems: 'center', gap: 0.5, - fontSize: '0.75rem', + fontSize: fontSize.xs, color: '#90caf9', textDecoration: 'underline', '&:hover': { color: '#fff' }, @@ -300,7 +301,7 @@ function ImplementationCard({ sx: { maxWidth: { xs: '80vw', sm: 400 }, fontFamily: '"MonoLisa", monospace', - fontSize: '0.8rem', + fontSize: fontSize.md, }, }, }} @@ -311,10 +312,10 @@ function ImplementationCard({ onTooltipToggle(isTooltipOpen ? null : tooltipId); }} sx={{ - fontSize: '0.8rem', + fontSize: fontSize.md, fontWeight: 600, fontFamily: '"MonoLisa", monospace', - color: isTooltipOpen ? '#3776AB' : '#9ca3af', + color: isTooltipOpen ? '#3776AB' : semanticColors.labelText, textTransform: 'lowercase', cursor: 'pointer', '&:hover': { color: '#3776AB' }, @@ -327,13 +328,13 @@ function ImplementationCard({ {impl.quality_score && ( <> - · + · {Math.round(impl.quality_score)} diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx index 4be4f3bf46..fe1745c943 100644 --- a/app/src/components/SpecTabs.tsx +++ b/app/src/components/SpecTabs.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, lazy, Suspense } from 'react'; +import { useState, useCallback, useEffect, lazy, Suspense } from 'react'; import Box from '@mui/material/Box'; import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; @@ -17,6 +17,12 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; const CodeHighlighter = lazy(() => import('./CodeHighlighter')); +import { fontSize, semanticColors } from '../theme'; +import { API_URL } from '../constants'; + + +// Cached global tag counts — loaded once, shared across all SpecTabs instances +let cachedTagCounts: Record> | null = null; // Map tag category names to URL parameter names const SPEC_TAG_PARAM_MAP: Record = { @@ -180,6 +186,27 @@ export function SpecTabs({ // In overview mode, start with Spec tab open; in detail mode, all collapsed const [tabIndex, setTabIndex] = useState(null); const [expandedCategories, setExpandedCategories] = useState>({}); + const [tagCounts, setTagCounts] = useState> | null>(cachedTagCounts); + + // Fetch global tag counts once (module-level cache) + useEffect(() => { + if (cachedTagCounts) { setTagCounts(cachedTagCounts); return; } + fetch(`${API_URL}/plots/filter?limit=1`) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.globalCounts) { + cachedTagCounts = data.globalCounts; + setTagCounts(data.globalCounts); + } + }) + .catch(() => {}); + }, []); + + // Get count for a tag value (e.g., "scatter" in "plot" category → 421 implementations) + const getTagCount = useCallback((paramName: string | undefined, value: string): number | null => { + if (!tagCounts || !paramName) return null; + return tagCounts[paramName]?.[value] ?? null; + }, [tagCounts]); // Handle tag click - navigate to filtered catalog (full page navigation) const handleTagClick = useCallback( @@ -638,21 +665,22 @@ export function SpecTabs({ {/* Tags — always visible after tab content (spec tags + impl tags on detail page) */} {((tags && Object.keys(tags).length > 0) || (implTags && Object.values(implTags).some(v => v?.length > 0))) && ( - + {tags && Object.entries(tags).map(([category, values]) => { const paramName = SPEC_TAG_PARAM_MAP[category]; return ( - + {category.replace(/_/g, ' ')}: {values.map((value, i) => { const isHighlighted = highlightedTags.includes(value); - return ( + const count = getTagCount(paramName, value); + const chip = ( handleTagClick(paramName, value) : undefined} sx={{ - fontFamily: '"MonoLisa", monospace', fontSize: '0.65rem', height: 20, + fontFamily: '"MonoLisa", monospace', fontSize: fontSize.xs, height: 24, bgcolor: isHighlighted ? '#dbeafe' : '#f3f4f6', color: isHighlighted ? '#1e40af' : '#4b5563', cursor: paramName ? 'pointer' : 'default', @@ -662,6 +690,9 @@ export function SpecTabs({ }} /> ); + return count !== null ? ( + {chip} + ) : chip; })} ); @@ -672,16 +703,17 @@ export function SpecTabs({ const paramName = IMPL_TAG_PARAM_MAP[category]; return ( - + {category}: {values.map((value, i) => { const isHighlighted = highlightedTags.includes(value); - return ( + const count = getTagCount(paramName, value); + const chip = ( handleTagClick(paramName, value) : undefined} sx={{ - fontFamily: '"MonoLisa", monospace', fontSize: '0.65rem', height: 20, + fontFamily: '"MonoLisa", monospace', fontSize: fontSize.xs, height: 24, bgcolor: isHighlighted ? '#dbeafe' : '#f3f4f6', color: isHighlighted ? '#1e40af' : '#4b5563', cursor: paramName ? 'pointer' : 'default', @@ -691,6 +723,9 @@ export function SpecTabs({ }} /> ); + return count !== null ? ( + {chip} + ) : chip; })} ); @@ -699,7 +734,7 @@ export function SpecTabs({ )} {/* Metadata footer — always visible */} - + {specId} {!overviewMode && libraryId && ` · ${libraryId}`} {(() => { diff --git a/app/src/components/ToolbarActions.tsx b/app/src/components/ToolbarActions.tsx index 6dfacbd4ac..2b8e1e6cd1 100644 --- a/app/src/components/ToolbarActions.tsx +++ b/app/src/components/ToolbarActions.tsx @@ -11,6 +11,7 @@ import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ListIcon from '@mui/icons-material/List'; import type { ImageSize } from '../constants'; +import { semanticColors } from '../theme'; interface ToolbarActionsProps { imageSize: ImageSize; @@ -31,13 +32,13 @@ export function CatalogLink() { display: 'flex', alignItems: 'center', justifyContent: 'center', - width: 32, - height: 32, - color: '#9ca3af', + width: 36, + height: 36, + color: semanticColors.mutedText, '&:hover': { color: '#3776AB' }, }} > - + ); @@ -72,18 +73,18 @@ export function GridSizeToggle({ imageSize, onImageSizeChange, onTrackEvent }: T display: 'flex', alignItems: 'center', justifyContent: 'center', - width: 32, - height: 32, + width: 36, + height: 36, cursor: 'pointer', - color: '#9ca3af', + color: semanticColors.mutedText, '&:hover': { color: '#3776AB' }, - '&:focus': { outline: '2px solid #3776AB', outlineOffset: 2 }, + '&:focus-visible': { outline: '2px solid #3776AB', outlineOffset: 2 }, }} > {imageSize === 'normal' ? ( - + ) : ( - + )} diff --git a/app/src/main.tsx b/app/src/main.tsx index 1783562942..352e839b3d 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -15,14 +15,14 @@ const theme = createTheme({ palette: { mode: 'light', primary: { - main: '#2563eb', // Slightly softer blue + main: '#3776AB', // Python blue }, text: { primary: '#1f2937', - secondary: '#6b7280', + secondary: '#52525b', }, background: { - default: '#ffffff', + default: '#fafafa', }, }, shape: { @@ -32,7 +32,25 @@ const theme = createTheme({ MuiCssBaseline: { styleOverrides: { body: { - backgroundColor: '#ffffff', + backgroundColor: '#fafafa', + }, + }, + }, + MuiTooltip: { + defaultProps: { + enterDelay: 200, + placement: 'top' as const, + }, + styleOverrides: { + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', + fontSize: '0.75rem', + padding: '4px 8px', + borderRadius: 4, + }, + arrow: { + color: 'rgba(0,0,0,0.8)', }, }, }, diff --git a/app/src/pages/CatalogPage.tsx b/app/src/pages/CatalogPage.tsx index 4a6263c43e..ea1633c080 100644 --- a/app/src/pages/CatalogPage.tsx +++ b/app/src/pages/CatalogPage.tsx @@ -14,6 +14,7 @@ import { useAppData, useHomeState } from '../hooks'; import { Breadcrumb } from '../components/Breadcrumb'; import { Footer } from '../components/Footer'; import type { PlotImage } from '../types'; +import { fontSize, semanticColors } from '../theme'; interface CatalogSpec { id: string; @@ -166,7 +167,7 @@ export function CatalogPage() { rel="noopener noreferrer" onClick={() => trackEvent('suggest_spec')} sx={{ - color: '#9ca3af', + color: semanticColors.mutedText, textDecoration: 'none', '&:hover': { color: '#3776AB' }, }} @@ -193,9 +194,9 @@ export function CatalogPage() { component="span" sx={{ ml: 2, - fontSize: '1rem', + fontSize: fontSize.lg, fontWeight: 400, - color: '#9ca3af', + color: semanticColors.mutedText, }} > {catalogSpecs.length} specifications @@ -296,7 +297,7 @@ export function CatalogPage() { py: 0.25, bgcolor: 'rgba(0,0,0,0.6)', borderRadius: 1, - fontSize: '0.7rem', + fontSize: fontSize.xs, fontFamily: '"MonoLisa", monospace', color: '#fff', opacity: 0, @@ -318,7 +319,7 @@ export function CatalogPage() { py: 0.25, bgcolor: 'rgba(0,0,0,0.6)', borderRadius: 0.5, - fontSize: '0.65rem', + fontSize: fontSize.xs, fontFamily: '"MonoLisa", monospace', color: '#fff', opacity: 0, @@ -347,7 +348,7 @@ export function CatalogPage() { sx={{ fontFamily: '"MonoLisa", monospace', fontWeight: 600, - fontSize: '1rem', + fontSize: fontSize.xl, color: '#1f2937', mb: 0.5, '&:hover': { color: '#3776AB' }, @@ -366,8 +367,8 @@ export function CatalogPage() { }} sx={{ fontFamily: '"MonoLisa", monospace', - fontSize: '0.85rem', - color: '#6b7280', + fontSize: fontSize.base, + color: semanticColors.subtleText, lineHeight: 1.6, cursor: expandedDescs[spec.id] ? 'default' : 'pointer', ...(!expandedDescs[spec.id] && { diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index 319c1d605d..6c6bc589d3 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -8,9 +8,11 @@ import Tooltip from '@mui/material/Tooltip'; import Skeleton from '@mui/material/Skeleton'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import BugReportIcon from '@mui/icons-material/BugReport'; +import ListIcon from '@mui/icons-material/List'; import { NotFoundPage } from './NotFoundPage'; import { API_URL, GITHUB_URL } from '../constants'; +import { fontSize, semanticColors } from '../theme'; import { useAnalytics, useCodeFetch } from '../hooks'; import { useAppData } from '../hooks'; import { LibraryPills } from '../components/LibraryPills'; @@ -21,6 +23,7 @@ import { RelatedSpecs } from '../components/RelatedSpecs'; const SpecTabs = lazy(() => import('../components/SpecTabs').then(m => ({ default: m.SpecTabs }))); const SpecOverview = lazy(() => import('../components/SpecOverview').then(m => ({ default: m.SpecOverview }))); const SpecDetailView = lazy(() => import('../components/SpecDetailView').then(m => ({ default: m.SpecDetailView }))); +const ImageLightbox = lazy(() => import('../components/ImageLightbox').then(m => ({ default: m.ImageLightbox }))); import type { Implementation } from '../types'; interface SpecDetail { @@ -50,6 +53,7 @@ export function SpecPage() { const [codeCopied, setCodeCopied] = useState(null); const [openTooltip, setOpenTooltip] = useState(null); const [highlightedTags, setHighlightedTags] = useState([]); + const [lightboxOpen, setLightboxOpen] = useState(false); const { fetchCode, getCode } = useCodeFetch(); // Get library metadata by ID @@ -129,11 +133,6 @@ export function SpecPage() { [specId, navigate] ); - // Handle image click (in detail mode - go back to overview) - const handleImageClick = useCallback(() => { - navigate(`/${specId}`); - }, [specId, navigate]); - // Handle download const handleDownload = useCallback( (impl: Implementation) => { @@ -193,6 +192,34 @@ export function SpecPage() { } }, [specData, isOverviewMode, selectedLibrary, specId, trackPageview]); + // Keyboard shortcuts: left/right arrows switch libraries in detail mode + useEffect(() => { + if (isOverviewMode || !specData) return; + + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return; + if (lightboxOpen) return; + + const sorted = [...specData.implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)); + const idx = sorted.findIndex((impl) => impl.library_id === selectedLibrary); + if (idx < 0) return; + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + const prev = (idx - 1 + sorted.length) % sorted.length; + handleLibrarySelect(sorted[prev].library_id); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + const next = (idx + 1) % sorted.length; + handleLibrarySelect(sorted[next].library_id); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOverviewMode, specData, selectedLibrary, lightboxOpen, handleLibrarySelect]); + // Loading state if (loading) { return ( @@ -256,45 +283,71 @@ export function SpecPage() { isOverviewMode ? [ { label: 'pyplots.ai', to: '/' }, - { label: 'catalog', to: '/catalog' }, { label: specId || '' }, ] : [ { label: 'pyplots.ai', to: '/' }, - { label: 'catalog', to: '/catalog' }, { label: specId || '', to: `/${specId}` }, { label: selectedLibrary || '' }, ] } rightAction={ - - trackEvent('report_issue', { spec: specId, library: selectedLibrary || undefined })} - sx={{ - color: '#9ca3af', - textDecoration: 'none', - display: 'flex', - alignItems: 'center', - '&:hover': { color: '#3776AB' }, - }} - > - + + - report issue + + + catalog + - - + + · + + trackEvent('report_issue', { spec: specId, library: selectedLibrary || undefined })} + sx={{ + color: semanticColors.mutedText, + textDecoration: 'none', + display: 'flex', + alignItems: 'center', + '&:hover': { color: '#3776AB' }, + }} + > + + + report issue + + + + } /> @@ -306,7 +359,7 @@ export function SpecPage() { textAlign: 'center', fontFamily: '"MonoLisa", monospace', fontWeight: 600, - fontSize: { xs: '1.25rem', sm: '1.5rem', md: '2rem' }, + fontSize: { xs: '1.375rem', sm: '1.625rem', md: '2.125rem' }, mb: 1, color: '#1f2937', }} @@ -320,8 +373,8 @@ export function SpecPage() { sx={{ textAlign: 'center', fontFamily: '"MonoLisa", monospace', - fontSize: { xs: '0.8rem', sm: '0.9rem' }, - color: '#6b7280', + fontSize: { xs: '0.875rem', sm: '0.9375rem' }, + color: '#52525b', maxWidth: { xs: '100%', md: 800, lg: 950, xl: 1100 }, mx: 'auto', mb: 2, @@ -387,6 +440,18 @@ export function SpecPage() { onSelect={handleLibrarySelect} /> + + + {'< all implementations'} + + + setImageLoaded(true)} - onImageClick={handleImageClick} + onImageZoom={() => setLightboxOpen(true)} + onCopyCode={handleCopyCode} + onDownload={handleDownload} + onTrackEvent={trackEvent} + /> + + setLightboxOpen(false)} + specId={specId || ''} + specTitle={specData.title} + currentImpl={currentImpl} + implementations={specData.implementations} + codeCopied={codeCopied} + onSelectLibrary={handleLibrarySelect} onCopyCode={handleCopyCode} onDownload={handleDownload} onTrackEvent={trackEvent} diff --git a/app/src/theme/index.ts b/app/src/theme/index.ts index a594d85f35..d4552f6c9c 100644 --- a/app/src/theme/index.ts +++ b/app/src/theme/index.ts @@ -38,13 +38,20 @@ export const colors = { background: '#fafafa', } as const; +// Semantic text colors — WCAG AA safe on #fafafa/#fff backgrounds +export const semanticColors = { + labelText: '#4b5563', // gray.600 — 7.0:1 on white, labels/categories + subtleText: '#52525b', // ~5.8:1 on white, secondary/metadata text + mutedText: '#6b7280', // gray.500, 4.6:1 — decorative/less critical text +} as const; + export const fontSize = { - xs: '0.65rem', - sm: '0.75rem', - md: '0.8rem', - base: '0.85rem', - lg: '0.9rem', - xl: '1rem', + xs: '0.75rem', + sm: '0.8rem', + md: '0.875rem', + base: '0.9375rem', + lg: '1rem', + xl: '1.125rem', } as const; export const spacing = { @@ -63,5 +70,5 @@ export const monoText = { export const labelStyle = { fontFamily: typography.fontFamily, fontSize: fontSize.md, - color: colors.gray[400], + color: semanticColors.labelText, } as const; diff --git a/docs/reference/style-guide.md b/docs/reference/style-guide.md new file mode 100644 index 0000000000..0a4e5e8d4d --- /dev/null +++ b/docs/reference/style-guide.md @@ -0,0 +1,210 @@ +# Style Guide + +Design system for the pyplots.ai frontend. All values are defined in `app/src/theme/index.ts` and `app/src/main.tsx`. + +## Colors + +### Brand + +| Token | Hex | Usage | +|-------|-----|-------| +| `colors.primary` | `#3776AB` | Python blue — primary brand, links, active states | +| `colors.accent` | `#FFD43B` | Python yellow — small accents only (logo, loader) | + +### Gray Scale + +| Token | Hex | Contrast on #fff | Usage | +|-------|-----|-------------------|-------| +| `gray.900` | `#111827` | 15.4:1 | — | +| `gray.800` | `#1f2937` | 13.5:1 | Primary text | +| `gray.700` | `#374151` | 10.3:1 | Headings, hover states | +| `gray.600` | `#4b5563` | 7.0:1 | Labels, categories | +| `gray.500` | `#6b7280` | 4.6:1 | Muted text, decorative | +| `gray.400` | `#9ca3af` | 2.9:1 | Borders, dividers only (fails AA for text) | +| `gray.300` | `#d1d5db` | — | Subtle borders | +| `gray.200` | `#e5e7eb` | — | Card borders, dividers | +| `gray.100` | `#f3f4f6` | — | Breadcrumb background, chips | +| `gray.50` | `#f9fafb` | — | Subtle backgrounds | + +### Semantic Text Colors + +WCAG AA requires 4.5:1 contrast for normal text, 3:1 for large text (>=18px or >=14px bold). + +| Token | Hex | Contrast | Usage | +|-------|-----|----------|-------| +| `semanticColors.labelText` | `#4b5563` | 7.0:1 | Labels, tag categories, card labels | +| `semanticColors.subtleText` | `#52525b` | 5.8:1 | Secondary text, descriptions, metadata | +| `semanticColors.mutedText` | `#6b7280` | 4.6:1 | Decorative text, footer links, breadcrumb separators | + +### Semantic Status + +| Token | Hex | Usage | +|-------|-----|-------| +| `colors.success` | `#22c55e` | Success states | +| `colors.error` | `#ef4444` | Error states | +| `colors.warning` | `#f59e0b` | Warnings | +| `colors.info` | `#3b82f6` | Info states | + +## Typography + +### Font Family + +``` +"MonoLisa", "MonoLisa Fallback", Consolas, Menlo, Monaco, "DejaVu Sans Mono", monospace +``` + +MonoLisa is a premium monospace font loaded from GCS with unicode-range subsets. The fallback uses size-adjusted system monospace fonts (106.5% scale) to prevent layout shift. + +### Font Scale + +| Token | Size | Px (at 16px base) | Usage | +|-------|------|-------|-------| +| `fontSize.xs` | 0.75rem | 12px | Tiny labels, tag chips, keyboard hints | +| `fontSize.sm` | 0.8rem | 12.8px | Small text, shared tags, metadata footer | +| `fontSize.md` | 0.875rem | 14px | Body default, card labels, footer | +| `fontSize.base` | 0.9375rem | 15px | Primary body text, breadcrumb, filter chips | +| `fontSize.lg` | 1rem | 16px | Large text, category headers | +| `fontSize.xl` | 1.125rem | 18px | Titles, prominent text | + +### Responsive Titles + +| Element | xs | sm | md | +|---------|----|----|------| +| Logo (pyplots.ai) | 2rem | 2.75rem | 3.75rem | +| Spec page title | 1.375rem | 1.625rem | 2.125rem | +| Spec description | 0.875rem | 0.9375rem | — | + +## Spacing + +MUI spacing units (1 unit = 8px): + +| Token | Units | Pixels | Usage | +|-------|-------|--------|-------| +| `spacing.xs` | 0.5 | 4px | Tight gaps | +| `spacing.sm` | 1 | 8px | Small gaps, chip spacing | +| `spacing.md` | 1.5 | 12px | Card padding, component gaps | +| `spacing.lg` | 2 | 16px | Section spacing | +| `spacing.xl` | 3 | 24px | Grid gaps, large sections | + +## Responsive Breakpoints + +MUI defaults: + +| Name | Width | Typical Device | +|------|-------|---------------| +| xs | 0px | Mobile phones | +| sm | 600px | Tablets (portrait) | +| md | 900px | Tablets (landscape), small desktop | +| lg | 1200px | Desktop | +| xl | 1536px | Large desktop, ultrawide | + +### Layout Container + +``` +padding-x: { xs: 16px, sm: 32px, md: 64px, lg: 96px, xl: 128px } +max-width: 2200px (centered) +``` + +### Content Max-Width (Spec Pages) + +``` +{ xs: 100%, md: 1200px, lg: 1400px, xl: 1800px } +``` + +### Grid Columns + +| Context | xs | sm | md | lg | xl | +|---------|----|----|----|----|------| +| Home (normal) | 1 | 1 | 2 | 2 | 3 | +| Home (compact) | 2 | 2 | 4 | 4 | 6 | +| Spec overview | 1 | 2 | 3 | 3 | 3 | +| Related specs | Single row, auto-fit `minmax(130px, 1fr)`, overflow hidden | + +## Components + +### Cards (ImageCard) + +- Border: `2px solid rgba(55, 118, 171, 0.3)` +- Border radius: 24px (MUI `borderRadius: 3`) +- Shadow: `0 2px 8px rgba(0,0,0,0.1)` +- Hover: border `0.4` opacity, shadow `0 8px 30px rgba(0,0,0,0.15)`, scale `1.03` +- Transition: `all 0.3s ease` +- Image aspect ratio: `16/10` +- Focus: `outline: none`, `focus-visible` only (no mouse focus ring) +- Copy confirmation: `>>> copied` overlay centered on image (dark bg, white text, 2s duration) + +### Tag Chips + +- Height: 24px +- Font: `fontSize.xs` (0.75rem) +- Default: `bgcolor: #f3f4f6`, `color: #4b5563` +- Highlighted: `bgcolor: #dbeafe`, `color: #1e40af`, `fontWeight: 600` +- Hover (clickable): `bgcolor: #e5e7eb` +- Tooltip: shows count of matching plots (e.g., `421 plots`), loaded once from `/plots/filter` globalCounts + +### Library Pills + +- Font: `fontSize.base` (0.9375rem) +- Active: `border: 1px solid #3776AB`, `fontWeight: 600`, `color: #374151` +- Inactive: `border: transparent`, `color: semanticColors.mutedText` +- Background: `#f3f4f6` + +### Lightbox + +- Backdrop: `rgba(0,0,0,0.85)` +- Image: `max-height: 90vh`, `max-width: 95vw`, `object-fit: contain` +- Close button: top-right, white icon on semi-transparent background +- Bottom bar: frosted glass (`backdrop-filter: blur(10px)`) +- Navigation arrows: centered vertically, semi-transparent circles + +### Tooltips (global) + +All tooltips share a consistent style defined in the MUI theme (`main.tsx`): + +- Background: `rgba(0,0,0,0.8)` +- Font: MonoLisa monospace, `0.75rem` +- Padding: `4px 8px` +- Border radius: `4px` +- Enter delay: `200ms` +- Placement: `top` (default) +- Arrow color matches background + +### Action Buttons (Copy, Download, Interactive) + +- Background: `rgba(255,255,255,0.9)` +- Appear on card/image hover (`opacity: 0` -> `1`) +- Hover: `bgcolor: #fff`, `color: #3776AB` (Python blue highlight) +- Size: `small` (MUI IconButton) + +### Toolbar Icons (Catalog, Grid Toggle) + +- Size: `1.4rem` icons in `36px` hit targets +- Color: `semanticColors.mutedText` -> hover `#3776AB` +- Focus: `focus-visible` only (no mouse focus ring) + +### Related Specs + +- Grid: `auto-fit`, `minmax(130px, 1fr)`, single row with overflow hidden +- Cards that don't fit are hidden (no partial cards, no scrollbar) +- Library names abbreviated (`mpl`, `sns`, `ply`, etc.) with full name in tooltip +- Title and tag count have native tooltips for truncated text + +## Animations + +| Name | Duration | Easing | Usage | +|------|----------|--------|-------| +| Card fade-in | 0.4s | ease-out | First batch of image cards | +| Card hover | 0.3s | ease | Border, shadow, scale | +| Chip roll | 0.5s | ease-in/out | Filter chip add/remove | +| Shuffle wiggle | 0.8s | ease | Random button icon | +| Loader | 2s | linear | Loading spinner | + +## Accessibility + +- All text must pass WCAG AA contrast (4.5:1 for normal, 3:1 for large) +- Never use `#9ca3af` or lighter for text on white/off-white backgrounds +- Interactive elements need visible focus states +- Images have descriptive `alt` text: `"{spec title} - {library}"` +- Keyboard navigation: arrow keys for library switching, Escape for lightbox +- Focus rings: `outline: none` on cards, `focus-visible` for keyboard-only focus indicators +- Tooltips: all interactive elements have descriptive tooltips From 82b836f7668cabdecab647e2761d4b88ac27a3c3 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:09:38 +0200 Subject: [PATCH 2/6] style: update UI components for improved usability and aesthetics - increase icon sizes for better visibility - adjust related specs query limit to 24 - enhance tab navigation in RelatedSpecs component - remove unnecessary elements from UI for cleaner design --- .claude/settings.json | 4 +- agentic/commands/prime.md | 26 ++++-- api/routers/insights.py | 2 +- app/src/components/ImageCard.tsx | 7 +- app/src/components/LibraryPills.tsx | 13 --- app/src/components/RelatedSpecs.tsx | 54 +++++++++--- app/src/components/SpecDetailView.tsx | 120 +++++++++++++++++++++----- app/src/components/SpecOverview.tsx | 71 +++++++++------ app/src/components/SpecTabs.tsx | 23 ++--- app/src/pages/InteractivePage.tsx | 1 - app/src/pages/SpecPage.tsx | 37 +++----- 11 files changed, 238 insertions(+), 120 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index b5a89f1da1..07aa7b459b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,9 +5,9 @@ "Edit", "Bash", "WebFetch", - "mcp__plugin_serena_serena__*", "mcp__plugin_context7_context7__*", - "mcp__plugin_playwright_playwright__*" + "mcp__plugin_playwright_playwright__*", + "mcp__serena__*" ], "ask": [ "Bash(git commit *)", diff --git a/agentic/commands/prime.md b/agentic/commands/prime.md index 9df27aee55..c88b9ee8b3 100644 --- a/agentic/commands/prime.md +++ b/agentic/commands/prime.md @@ -24,15 +24,25 @@ gh pr list --limit 10 2>/dev/null || echo "(gh CLI not available)" ## Serena -- Run `activate_project` with project "pyplots" -- Run `list_memories` and read relevant ones - Run `check_onboarding_performed` +- Run `list_memories` and read relevant ones + +### JetBrains Tools (prefer over brute-force scanning) + +Use Serena's JetBrains-backed tools for code navigation — they provide semantic understanding +that grep/glob cannot: + +- `jet_brains_get_symbols_overview` — get top-level symbols in a file (classes, functions, variables). Use with `depth: 1` to also see methods of classes. Start here to understand a file before diving deeper. +- `jet_brains_find_symbol` — search for a symbol by name across the codebase. Supports name path patterns like `MyClass/my_method`. Use `include_body: true` to read source code, `include_info: true` for docstrings/signatures. +- `jet_brains_find_referencing_symbols` — find all usages of a symbol (who calls this function? who imports this class?). Essential for understanding impact of changes. +- `jet_brains_find_declaration` — jump to where a symbol is defined. +- `jet_brains_find_implementations` — find implementations of an interface/abstract class. +- `jet_brains_type_hierarchy` — understand class inheritance chains. -Prefer Serena's symbolic tools (`jet_brains_find_symbol`, `jet_brains_get_symbols_overview`, -`jet_brains_find_referencing_symbols`) over brute-force file scanning. +### Editing via Serena -Use Serena's thinking tools to maintain focus: +For structural edits, prefer Serena's symbol-aware tools over raw text replacement: -- `think_about_collected_information` - after research/search sequences -- `think_about_task_adherence` - before making code changes -- `think_about_whether_you_are_done` - when task seems complete +- `replace_symbol_body` — replace an entire function/class body +- `insert_after_symbol` / `insert_before_symbol` — add code relative to a symbol +- `search_for_pattern` — regex search across the codebase (fast, flexible) diff --git a/api/routers/insights.py b/api/routers/insights.py index 9cb668e523..939720e18b 100644 --- a/api/routers/insights.py +++ b/api/routers/insights.py @@ -525,7 +525,7 @@ async def _build_related( @router.get("/related/{spec_id}", response_model=RelatedSpecsResponse) async def get_related_specs( spec_id: str, - limit: int = Query(default=6, ge=1, le=12), + limit: int = Query(default=6, ge=1, le=24), mode: str = Query(default="spec", pattern="^(spec|full)$"), library: str | None = Query(default=None), db: AsyncSession = Depends(require_db), diff --git a/app/src/components/ImageCard.tsx b/app/src/components/ImageCard.tsx index eb5f45980b..863ed2a860 100644 --- a/app/src/components/ImageCard.tsx +++ b/app/src/components/ImageCard.tsx @@ -215,7 +215,6 @@ export const ImageCard = memo(function ImageCard({ onClick={handleCopyCode} disabled={copyState === 'loading'} aria-label="Copy code" - size="small" sx={{ position: 'absolute', top: 8, @@ -229,11 +228,11 @@ export const ImageCard = memo(function ImageCard({ }} > {copyState === 'loading' ? ( - + ) : copyState === 'copied' ? ( - + ) : ( - + )} diff --git a/app/src/components/LibraryPills.tsx b/app/src/components/LibraryPills.tsx index 843972250b..7867e0d26f 100644 --- a/app/src/components/LibraryPills.tsx +++ b/app/src/components/LibraryPills.tsx @@ -116,7 +116,6 @@ export const LibraryPills = memo(function LibraryPills({ > {visibleItems.map(({ impl, position }) => { const isCenter = position === 'center'; - const score = impl.quality_score; return ( {isCenter ? impl.library_id : (LIBRARY_ABBREV[impl.library_id] || impl.library_id)} - {score && isCenter && ( - - {Math.round(score)} - - )} ); })} diff --git a/app/src/components/RelatedSpecs.tsx b/app/src/components/RelatedSpecs.tsx index 9a8bc88d70..da1685c82b 100644 --- a/app/src/components/RelatedSpecs.tsx +++ b/app/src/components/RelatedSpecs.tsx @@ -3,6 +3,9 @@ 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'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import { API_URL } from '../constants'; import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; @@ -46,10 +49,11 @@ interface RelatedSpecsProps { export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: RelatedSpecsProps) { const [related, setRelated] = useState([]); + const [expanded, setExpanded] = useState(false); useEffect(() => { let cancelled = false; - const params = new URLSearchParams({ limit: '6', mode }); + const params = new URLSearchParams({ limit: '24', mode }); if (library && mode === 'full') params.set('library', library); fetch(`${API_URL}/insights/related/${specId}?${params}`) .then(r => { if (!r.ok) throw new Error(); return r.json(); }) @@ -58,20 +62,50 @@ export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: Re return () => { cancelled = true; }; }, [specId, mode, library]); + useEffect(() => { + setExpanded(false); + }, [specId]); + if (related.length === 0) return null; + // Collapsed: CSS hides extra rows via gridAutoRows:0 + overflow:hidden + return ( - - - {mode === 'full' ? 'similar implementations' : 'similar specifications'} - + + + setExpanded((e) => !e)} + variant="fullWidth" + sx={{ + '& .MuiTab-root': { + fontFamily: '"MonoLisa", monospace', + textTransform: 'none', + fontSize: '0.875rem', + minHeight: 48, + transition: 'background-color 0.15s ease, color 0.15s ease', + borderRadius: '4px 4px 0 0', + '&:hover': { backgroundColor: '#f3f4f6', color: '#3776AB' }, + }, + '& .Mui-selected': { color: '#3776AB' }, + '& .MuiTabs-indicator': { backgroundColor: '#3776AB' }, + }} + > + expanded && setExpanded(false)} + icon={} + iconPosition="start" + label="Similar" + /> + + {related.map(spec => ( void; - onImageZoom: () => void; onCopyCode: (impl: Implementation) => void; onDownload: (impl: Implementation) => void; onTrackEvent: (event: string, props?: Record) => void; @@ -40,8 +41,8 @@ export function SpecDetailView({ implementations, imageLoaded, codeCopied, + downloadDone, onImageLoad, - onImageZoom, onCopyCode, onDownload, onTrackEvent, @@ -50,6 +51,59 @@ export function SpecDetailView({ const sortedImpls = [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)); const currentIndex = sortedImpls.findIndex((impl) => impl.library_id === selectedLibrary); + // In-place zoom + pan + const containerRef = useRef(null); + const [zoomed, setZoomed] = useState(false); + const [origin, setOrigin] = useState({ x: 50, y: 50 }); + const [animating, setAnimating] = useState(false); + + useEffect(() => { + setZoomed(false); + setOrigin({ x: 50, y: 50 }); + }, [selectedLibrary]); + + const handleZoomToggle = useCallback( + (e: React.MouseEvent) => { + if (!containerRef.current) return; + if (!zoomed) { + const rect = containerRef.current.getBoundingClientRect(); + setOrigin({ + x: ((e.clientX - rect.left) / rect.width) * 100, + y: ((e.clientY - rect.top) / rect.height) * 100, + }); + } + setAnimating(true); + setZoomed((z) => !z); + setTimeout(() => setAnimating(false), 300); + }, + [zoomed], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!zoomed || animating || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setOrigin({ + x: ((e.clientX - rect.left) / rect.width) * 100, + y: ((e.clientY - rect.top) / rect.height) * 100, + }); + }, + [zoomed, animating], + ); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!zoomed || animating || !containerRef.current) return; + const touch = e.touches[0]; + const rect = containerRef.current.getBoundingClientRect(); + setOrigin({ + x: ((touch.clientX - rect.left) / rect.width) * 100, + y: ((touch.clientY - rect.top) / rect.height) * 100, + }); + }, + [zoomed, animating], + ); + return ( ) => { const target = e.target as HTMLImageElement; @@ -124,6 +185,27 @@ export function SpecDetailView({ )} + {/* Copied/Downloaded confirmation overlay */} + {currentImpl && (codeCopied === currentImpl.library_id || downloadDone === currentImpl.library_id) && ( + + {codeCopied === currentImpl.library_id ? '>>> copied' : '>>> downloaded'} + + )} + {/* Action Buttons (top-right) - stop propagation */} e.stopPropagation()} @@ -131,46 +213,42 @@ export function SpecDetailView({ position: 'absolute', top: 8, right: 8, - display: 'flex', + display: zoomed ? 'none' : 'flex', gap: 0.5, }} > - {currentImpl?.code && ( - + {currentImpl && ( + onCopyCode(currentImpl)} + onClick={(e: React.MouseEvent) => { (e.currentTarget as HTMLElement).blur(); onCopyCode(currentImpl); }} aria-label="Copy code" sx={{ bgcolor: 'rgba(255,255,255,0.9)', '&:hover': { bgcolor: '#fff', color: '#3776AB' }, }} - size="small" + size="medium" > - {codeCopied === currentImpl.library_id ? ( - - ) : ( - - )} + )} {currentImpl && ( - + onDownload(currentImpl)} + onClick={(e: React.MouseEvent) => { (e.currentTarget as HTMLElement).blur(); onDownload(currentImpl); }} aria-label="Download PNG" sx={{ bgcolor: 'rgba(255,255,255,0.9)', '&:hover': { bgcolor: '#fff', color: '#3776AB' }, }} - size="small" + size="medium" > )} {currentImpl?.preview_html && ( - + @@ -192,7 +270,7 @@ export function SpecDetailView({ {/* Implementation counter (hover) */} - {implementations.length > 1 && ( + {implementations.length > 1 && !zoomed && ( void; onCopyCode: (impl: Implementation) => void; @@ -47,6 +47,7 @@ export function SpecOverview({ specTitle, implementations, codeCopied, + downloadDone, openTooltip, onImplClick, onCopyCode, @@ -76,6 +77,7 @@ export function SpecOverview({ specId={specId} specTitle={specTitle} codeCopied={codeCopied} + downloadDone={downloadDone} openTooltip={openTooltip} onImplClick={onImplClick} onCopyCode={onCopyCode} @@ -94,6 +96,7 @@ interface ImplementationCardProps { specId: string; specTitle: string; codeCopied: string | null; + downloadDone: string | null; openTooltip: string | null; onImplClick: (libraryId: string) => void; onCopyCode: (impl: Implementation) => void; @@ -108,6 +111,7 @@ function ImplementationCard({ specId, specTitle, codeCopied, + downloadDone, openTooltip, onImplClick, onCopyCode, @@ -181,6 +185,27 @@ function ImplementationCard({ )} + {/* Copied/Downloaded confirmation overlay */} + {(codeCopied === impl.library_id || downloadDone === impl.library_id) && ( + + {codeCopied === impl.library_id ? '>>> copied' : '>>> downloaded'} + + )} + {/* Action Buttons (top-right) */} - {impl.code && ( - - onCopyCode(impl)} - aria-label="Copy code" - sx={{ - bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff', color: '#3776AB' }, - }} - size="small" - > - {codeCopied === impl.library_id ? ( - - ) : ( - - )} - - - )} - + + { (e.currentTarget as HTMLElement).blur(); onCopyCode(impl); }} + aria-label="Copy code" + sx={{ + bgcolor: 'rgba(255,255,255,0.9)', + '&:hover': { bgcolor: '#fff', color: '#3776AB' }, + }} + size="medium" + > + + + + onDownload(impl)} + onClick={(e: React.MouseEvent) => { (e.currentTarget as HTMLElement).blur(); onDownload(impl); }} aria-label="Download PNG" sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, + '&:hover': { bgcolor: '#fff', color: '#3776AB' }, }} - size="small" + size="medium" > {impl.preview_html && ( - + diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx index fe1745c943..9b104c50f5 100644 --- a/app/src/components/SpecTabs.tsx +++ b/app/src/components/SpecTabs.tsx @@ -308,9 +308,9 @@ export function SpecTabs({ {!overviewMode && ( tabIndex === 0 && handleTabChange(e, 0)} icon={} iconPosition="start" label="Code" /> )} - tabIndex === specTabIndex && handleTabChange(e, specTabIndex)} icon={} iconPosition="start" label="Spec" /> + tabIndex === specTabIndex && handleTabChange(e, specTabIndex)} icon={} iconPosition="start" label={<>SpecificationSpec} /> {!overviewMode && ( - tabIndex === 2 && handleTabChange(e, 2)} icon={} iconPosition="start" label="Impl" /> + tabIndex === 2 && handleTabChange(e, 2)} icon={} iconPosition="start" label={<>ImplementationImpl} /> )} {!overviewMode && ( )} + + {/* Metadata */} + + {specId} + {libraryId && ` · ${libraryId}`} + {(() => { + const date = generatedAt || updated || created; + return date ? ` · ${formatDate(date)}` : ''; + })()} + )} @@ -733,15 +743,6 @@ export function SpecTabs({ )} - {/* Metadata footer — always visible */} - - {specId} - {!overviewMode && libraryId && ` · ${libraryId}`} - {(() => { - const date = !overviewMode ? (generatedAt || updated || created) : (updated || created); - return date ? ` · ${formatDate(date)}` : ''; - })()} - ); } diff --git a/app/src/pages/InteractivePage.tsx b/app/src/pages/InteractivePage.tsx index 6c4d32537f..f9a88230a6 100644 --- a/app/src/pages/InteractivePage.tsx +++ b/app/src/pages/InteractivePage.tsx @@ -218,7 +218,6 @@ export function InteractivePage() { import('../components/SpecTabs').then(m => ({ default: m.SpecTabs }))); const SpecOverview = lazy(() => import('../components/SpecOverview').then(m => ({ default: m.SpecOverview }))); const SpecDetailView = lazy(() => import('../components/SpecDetailView').then(m => ({ default: m.SpecDetailView }))); -const ImageLightbox = lazy(() => import('../components/ImageLightbox').then(m => ({ default: m.ImageLightbox }))); import type { Implementation } from '../types'; interface SpecDetail { @@ -53,7 +52,6 @@ export function SpecPage() { const [codeCopied, setCodeCopied] = useState(null); const [openTooltip, setOpenTooltip] = useState(null); const [highlightedTags, setHighlightedTags] = useState([]); - const [lightboxOpen, setLightboxOpen] = useState(false); const { fetchCode, getCode } = useCodeFetch(); // Get library metadata by ID @@ -74,6 +72,7 @@ export function SpecPage() { setLoading(true); setError(null); setImageLoaded(false); + setHighlightedTags([]); try { const res = await fetch(`${API_URL}/specs/${specId}`); @@ -134,20 +133,26 @@ export function SpecPage() { ); // Handle download + const [downloadDone, setDownloadDone] = useState(null); + const handleDownload = useCallback( - (impl: Implementation) => { - if (!impl?.preview_url) return; + async (impl: Implementation) => { + if (!specId) return; const link = document.createElement('a'); - link.href = impl.preview_url; + link.href = `${API_URL}/download/${specId}/${impl.library_id}`; link.download = `${specId}-${impl.library_id}.png`; + document.body.appendChild(link); link.click(); + document.body.removeChild(link); + setDownloadDone(impl.library_id); + setTimeout(() => setDownloadDone(null), 2000); trackEvent('download_image', { spec: specId, library: impl.library_id, page: isOverviewMode ? 'spec_overview' : 'spec_detail', }); }, - [specId, trackEvent, isOverviewMode] + [specId, trackEvent, isOverviewMode], ); // Handle copy code (fetches on-demand if not prefetched yet) @@ -199,8 +204,6 @@ export function SpecPage() { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return; - if (lightboxOpen) return; - const sorted = [...specData.implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)); const idx = sorted.findIndex((impl) => impl.library_id === selectedLibrary); if (idx < 0) return; @@ -218,7 +221,7 @@ export function SpecPage() { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOverviewMode, specData, selectedLibrary, lightboxOpen, handleLibrarySelect]); + }, [isOverviewMode, specData, selectedLibrary, handleLibrarySelect]); // Loading state if (loading) { @@ -400,6 +403,7 @@ export function SpecPage() { specTitle={specData.title} implementations={specData.implementations} codeCopied={codeCopied} + downloadDone={downloadDone} openTooltip={openTooltip} onImplClick={handleImplClick} onCopyCode={handleCopyCode} @@ -460,26 +464,13 @@ export function SpecPage() { implementations={specData.implementations} imageLoaded={imageLoaded} codeCopied={codeCopied} + downloadDone={downloadDone} onImageLoad={() => setImageLoaded(true)} - onImageZoom={() => setLightboxOpen(true)} onCopyCode={handleCopyCode} onDownload={handleDownload} onTrackEvent={trackEvent} /> - setLightboxOpen(false)} - specId={specId || ''} - specTitle={specData.title} - currentImpl={currentImpl} - implementations={specData.implementations} - codeCopied={codeCopied} - onSelectLibrary={handleLibrarySelect} - onCopyCode={handleCopyCode} - onDownload={handleDownload} - onTrackEvent={trackEvent} - /> Date: Sat, 11 Apr 2026 00:07:46 +0200 Subject: [PATCH 3/6] style: tokenize all frontend colors/fonts, fix WCAG contrast, redesign PlotOfTheDay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 80+ hardcoded hex colors with theme tokens across all components and pages - Fix 15+ WCAG AA contrast violations (#9ca3af used as text color) - Add new theme tokens: fontSize.micro/xxs, colors.primaryDark/accentBg/codeBlock/highlight/tooltipLight - Add shared style constants: headingStyle, subheadingStyle, textStyle, codeBlockStyle, tableStyle - Centralize LIB_ABBREV map in constants, add responsive shortLabel to Breadcrumb (pp/mpl/sns on mobile) - Redesign PlotOfTheDay: terminal-style frame, split layout, fade-in animation, GitHub link, real versions - Fix 3 pre-existing lint errors (setState in useEffect → render-time ref pattern) - Fix SpecPage useCallback missing dependency warning - Remove unused ImageLightbox component - Improve RelatedSpecs grid (2→3→4→6 columns), spacing, "tags in common" label - Update style guide documentation with all new tokens and sections - Update Serena memories with style guide reference Co-Authored-By: Claude Opus 4.6 (1M context) --- .serena/memories/code_style.md | 4 +- .serena/memories/style_guide.md | 44 ++++ api/routers/insights.py | 4 + app/src/components/Breadcrumb.tsx | 38 +-- app/src/components/CodeHighlighter.tsx | 3 +- app/src/components/FilterBar.tsx | 76 +++--- app/src/components/Footer.tsx | 18 +- app/src/components/Header.tsx | 28 +-- app/src/components/ImageCard.tsx | 26 +-- app/src/components/ImageLightbox.tsx | 312 ------------------------- app/src/components/ImagesGrid.tsx | 7 +- app/src/components/Layout.tsx | 3 +- app/src/components/LibraryPills.tsx | 20 +- app/src/components/LoaderSpinner.tsx | 17 +- app/src/components/PlotOfTheDay.tsx | 250 ++++++++++++++------ app/src/components/RelatedSpecs.tsx | 54 ++--- app/src/components/SpecDetailView.tsx | 21 +- app/src/components/SpecOverview.tsx | 22 +- app/src/components/SpecTabs.tsx | 128 +++++----- app/src/components/ToolbarActions.tsx | 8 +- app/src/constants/index.ts | 13 ++ app/src/main.tsx | 17 +- app/src/pages/CatalogPage.tsx | 28 +-- app/src/pages/DebugPage.tsx | 100 ++++---- app/src/pages/HomePage.tsx | 7 +- app/src/pages/InteractivePage.tsx | 15 +- app/src/pages/LegalPage.tsx | 114 ++++----- app/src/pages/McpPage.tsx | 95 ++------ app/src/pages/NotFoundPage.tsx | 7 +- app/src/pages/SpecPage.tsx | 42 ++-- app/src/pages/StatsPage.tsx | 85 +++---- app/src/theme/index.ts | 74 ++++++ docs/reference/style-guide.md | 95 ++++++-- 33 files changed, 859 insertions(+), 916 deletions(-) create mode 100644 .serena/memories/style_guide.md delete mode 100644 app/src/components/ImageLightbox.tsx diff --git a/.serena/memories/code_style.md b/.serena/memories/code_style.md index e068f362eb..fa3db364bd 100644 --- a/.serena/memories/code_style.md +++ b/.serena/memories/code_style.md @@ -18,8 +18,8 @@ - **Hooks**: camelCase with `use` prefix (e.g., `useSpecs.ts`) - **Utils/Types**: camelCase files (e.g., `api.ts`) - **Exports**: Named exports (no default exports) -- **Styling**: MUI `sx` prop + Emotion `styled()`, no CSS modules -- **State**: Local state + custom hooks (no Redux/Zustand) +- **Styling**: MUI `sx` prop + theme tokens from `app/src/theme/index.ts`. No hardcoded hex colors — use `colors.*`, `semanticColors.*`, `fontSize.*`, `typography.fontFamily` imports. See Serena memory `style_guide` for full token reference. +- **State**: Local state + custom hooks (no Redux/Zustand). Avoid `setState` inside `useEffect` for prop resets — use render-time ref pattern instead. - **API calls**: Plain `fetch()` in `utils/api.ts` ## Agentic Layer Conventions diff --git a/.serena/memories/style_guide.md b/.serena/memories/style_guide.md new file mode 100644 index 0000000000..5782fad1ab --- /dev/null +++ b/.serena/memories/style_guide.md @@ -0,0 +1,44 @@ +# Frontend Style Guide + +All visual values are centralized in `app/src/theme/index.ts`. Never use hardcoded hex colors or font strings — always import tokens. + +## Imports +```ts +import { typography, colors, semanticColors, fontSize, spacing } from '../theme'; +// Shared style constants: +import { headingStyle, subheadingStyle, textStyle, codeBlockStyle, tableStyle, labelStyle, monoText } from '../theme'; +``` + +## Colors +- **Brand**: `colors.primary` (#3776AB), `colors.accent` (#FFD43B), `colors.primaryDark` (#306998) +- **Gray scale**: `colors.gray[50]` to `colors.gray[900]` +- **Semantic text**: `semanticColors.labelText` (7.0:1), `semanticColors.subtleText` (5.8:1), `semanticColors.mutedText` (4.6:1) +- **Status**: `colors.success`, `colors.error`, `colors.warning`, `colors.info` +- **Backgrounds**: `colors.background`, `colors.accentBg` +- **Code blocks**: `colors.codeBlock.bg`, `colors.codeBlock.text` + +## WCAG Rule +`colors.gray[400]` (#9ca3af) and lighter **must never be used for text or icons**. Minimum text color: `semanticColors.mutedText` (#6b7280, 4.6:1 contrast). + +## Font +Always use `typography.fontFamily` — never inline `'"MonoLisa", monospace'` or raw `'monospace'`. +**Exception**: ErrorBoundary uses raw `'monospace'` intentionally (crash-safe fallback). + +## Font Sizes +`fontSize.micro` (0.5rem) and `fontSize.xxs` (0.625rem) are restricted to data-dense pages (StatsPage, DebugPage). +Public pages use `fontSize.xs` (0.75rem) as minimum. +Full scale: micro, xxs, xs, sm, md, base, lg, xl. + +## Shared Constants +- `headingStyle` — page h2 (1.25rem, 600, gray.800) +- `subheadingStyle` — page h3 (1rem, 600, gray.700) +- `textStyle` — body text (0.9375rem, labelText, lineHeight 1.8) +- `codeBlockStyle` — dark code blocks +- `tableStyle` — consistent table cells/headers +- `labelStyle` — small labels (0.875rem, labelText) + +## Highlight Colors (not tokenized) +`#dbeafe`/`#1e40af` (highlighted tag chips) and `#90caf9` (tooltip text on dark bg) are intentionally kept as direct values. + +## Full reference +See `docs/reference/style-guide.md` for complete documentation including spacing, breakpoints, component specs, animations, and accessibility rules. diff --git a/api/routers/insights.py b/api/routers/insights.py index 939720e18b..98fa4b04dd 100644 --- a/api/routers/insights.py +++ b/api/routers/insights.py @@ -109,6 +109,8 @@ class PlotOfTheDayResponse(BaseModel): preview_url: str | None = None image_description: str | None = None code: str | None = None + library_version: str | None = None + python_version: str | None = None date: str @@ -397,6 +399,8 @@ async def _build_potd(spec_repo: SpecRepository, impl_repo: ImplRepository) -> P preview_url=preview_url, image_description=full_impl.review_image_description if full_impl else None, code=strip_noqa_comments(full_impl.code) if full_impl and full_impl.code else None, + library_version=full_impl.library_version if full_impl else None, + python_version=full_impl.python_version if full_impl else None, date=today, ) diff --git a/app/src/components/Breadcrumb.tsx b/app/src/components/Breadcrumb.tsx index 1442b32345..3c03b3f868 100644 --- a/app/src/components/Breadcrumb.tsx +++ b/app/src/components/Breadcrumb.tsx @@ -5,10 +5,11 @@ import { Link } from 'react-router-dom'; import Box from '@mui/material/Box'; import type { SxProps, Theme } from '@mui/material/styles'; -import { fontSize, semanticColors } from '../theme'; +import { colors, fontSize, semanticColors, typography } from '../theme'; export interface BreadcrumbItem { label: string; + shortLabel?: string; // Shown on mobile (xs) if provided to?: string; // If undefined, this is the current page (not linked) } @@ -27,11 +28,12 @@ export interface BreadcrumbProps { * * * @example - * // With right action - * suggest spec} - * /> + * // With short labels for mobile + * */ export function Breadcrumb({ items, rightAction, sx }: BreadcrumbProps) { return ( @@ -47,9 +49,9 @@ export function Breadcrumb({ items, rightAction, sx }: BreadcrumbProps) { px: 2, py: 1, mb: 2, - bgcolor: '#f3f4f6', - borderBottom: '1px solid #e5e7eb', - fontFamily: '"MonoLisa", monospace', + bgcolor: colors.gray[100], + borderBottom: `1px solid ${colors.gray[200]}`, + fontFamily: typography.fontFamily, fontSize: fontSize.base, ...sx, }} @@ -68,16 +70,26 @@ export function Breadcrumb({ items, rightAction, sx }: BreadcrumbProps) { component={Link} to={item.to} sx={{ - color: '#3776AB', + color: colors.primary, textDecoration: 'none', '&:hover': { textDecoration: 'underline' }, }} > - {item.label} + {item.shortLabel ? ( + <> + {item.label} + {item.shortLabel} + + ) : item.label} ) : ( - - {item.label} + + {item.shortLabel ? ( + <> + {item.label} + {item.shortLabel} + + ) : item.label} )} diff --git a/app/src/components/CodeHighlighter.tsx b/app/src/components/CodeHighlighter.tsx index bbc49e6c20..ed5182c987 100644 --- a/app/src/components/CodeHighlighter.tsx +++ b/app/src/components/CodeHighlighter.tsx @@ -1,6 +1,7 @@ import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light'; import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; import python from 'react-syntax-highlighter/dist/esm/languages/prism/python'; +import { typography } from '../theme'; SyntaxHighlighter.registerLanguage('python', python); @@ -16,7 +17,7 @@ export default function CodeHighlighter({ code }: CodeHighlighterProps) { customStyle={{ margin: 0, fontSize: '0.85rem', - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', + fontFamily: typography.fontFamily, background: 'transparent', }} > diff --git a/app/src/components/FilterBar.tsx b/app/src/components/FilterBar.tsx index 2457759a7f..9e4b32fc74 100644 --- a/app/src/components/FilterBar.tsx +++ b/app/src/components/FilterBar.tsx @@ -19,7 +19,7 @@ import { FILTER_LABELS, FILTER_TOOLTIPS, FILTER_CATEGORIES } from '../types'; import type { ImageSize } from '../constants'; import { getAvailableValues, getAvailableValuesForGroup, getSearchResults, type SearchResult } from '../utils'; import { ToolbarActions } from './ToolbarActions'; -import { fontSize, semanticColors } from '../theme'; +import { fontSize, semanticColors, colors, typography } from '../theme'; interface FilterBarProps { activeFilters: ActiveFilters; @@ -318,8 +318,8 @@ export function FilterBar({ ? { mx: { xs: -2, sm: -4, md: -8, lg: -12 }, px: { xs: 2, sm: 4, md: 8, lg: 12 }, - bgcolor: '#f3f4f6', - borderBottom: '1px solid #e5e7eb', + bgcolor: colors.gray[100], + borderBottom: `1px solid ${colors.gray[200]}`, } : { px: 2, @@ -345,7 +345,7 @@ export function FilterBar({ sx={{ position: 'absolute', left: 0, - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', + fontFamily: typography.fontFamily, fontSize: fontSize.sm, color: semanticColors.mutedText, whiteSpace: 'nowrap', @@ -381,17 +381,17 @@ export function FilterBar({ onDelete={() => onRemoveGroup(index)} deleteIcon={} sx={{ - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', + fontFamily: typography.fontFamily, fontSize: fontSize.base, height: 32, - bgcolor: '#f3f4f6', - border: '1px solid #3776AB', - color: '#374151', + bgcolor: colors.gray[100], + border: `1px solid ${colors.primary}`, + color: colors.gray[700], cursor: 'pointer', - '&:hover': { bgcolor: '#e5e7eb' }, + '&:hover': { bgcolor: colors.gray[200] }, '& .MuiChip-deleteIcon': { - color: '#9ca3af', - '&:hover': { color: '#3776AB' }, + color: colors.gray[500], + '&:hover': { color: colors.primary }, }, ...(animationClass === 'chip-blur-out' && { animation: 'chip-roll-out 0.5s ease-in forwards', @@ -435,26 +435,26 @@ export function FilterBar({ height: 32, width: isSearchExpanded ? { xs: 80, sm: 160, md: 'auto' } : 32, minWidth: isSearchExpanded ? { xs: 80, sm: 160, md: 120 } : 32, - border: isSearchExpanded ? '1px dashed #9ca3af' : 'none', + border: isSearchExpanded ? `1px dashed ${colors.gray[400]}` : 'none', borderRadius: '16px', - bgcolor: isDropdownOpen ? '#f9fafb' : 'transparent', + bgcolor: isDropdownOpen ? colors.gray[50] : 'transparent', cursor: 'pointer', transition: 'all 0.2s ease', '&:hover': { - borderColor: isSearchExpanded ? '#3776AB' : undefined, - bgcolor: isSearchExpanded ? '#f9fafb' : undefined, + borderColor: isSearchExpanded ? colors.primary : undefined, + bgcolor: isSearchExpanded ? colors.gray[50] : undefined, }, '&:hover .search-icon': { - color: '#3776AB', + color: colors.primary, }, - '&:focus': isSearchExpanded ? {} : { outline: '2px solid #3776AB', outlineOffset: 2 }, + '&:focus': isSearchExpanded ? {} : { outline: `2px solid ${colors.primary}`, outlineOffset: 2 }, }} > )} @@ -535,7 +535,7 @@ export function FilterBar({ {currentTotal > 0 ? ( handleCategorySelect(category)} selected={visibleIdx === highlightedIndex} - sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }} + sx={{ fontFamily: typography.fontFamily }} > ← {FILTER_LABELS[selectedCategory]} , @@ -659,24 +659,24 @@ export function FilterBar({ key={`${category}-${value}`} onClick={() => handleValueSelect(category, value)} selected={idx === highlightedIndex} - sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }} + sx={{ fontFamily: typography.fontFamily }} > @@ -727,7 +727,7 @@ export function FilterBar({ @@ -777,9 +777,9 @@ export function FilterBar({ handleAddValueToExistingGroup(value)} - sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', py: 0.5 }} + sx={{ fontFamily: typography.fontFamily, py: 0.5 }} > - + {value} ({count}) @@ -792,9 +792,9 @@ export function FilterBar({ handleRemoveValue(value)} - sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }} + sx={{ fontFamily: typography.fontFamily }} > - + {value} )), @@ -805,7 +805,7 @@ export function FilterBar({ remove all diff --git a/app/src/components/Footer.tsx b/app/src/components/Footer.tsx index 34029058a9..f4f2739b4e 100644 --- a/app/src/components/Footer.tsx +++ b/app/src/components/Footer.tsx @@ -2,7 +2,7 @@ import Box from '@mui/material/Box'; import Link from '@mui/material/Link'; import { Link as RouterLink } from 'react-router-dom'; import { GITHUB_URL } from '../constants'; -import { fontSize, semanticColors } from '../theme'; +import { colors, fontSize, semanticColors, typography } from '../theme'; interface FooterProps { onTrackEvent?: (name: string, props?: Record) => void; @@ -12,7 +12,7 @@ interface FooterProps { export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterProps) { return ( - + @@ -32,7 +32,7 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr sx={{ color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#374151' }, + '&:hover': { color: colors.gray[700] }, }} > github @@ -45,7 +45,7 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr sx={{ color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#374151' }, + '&:hover': { color: colors.gray[700] }, }} > catalog @@ -58,7 +58,7 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr sx={{ color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#374151' }, + '&:hover': { color: colors.gray[700] }, }} > stats @@ -73,7 +73,7 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr sx={{ color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#374151' }, + '&:hover': { color: colors.gray[700] }, }} > markus neusinger @@ -87,7 +87,7 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr sx={{ color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#374151' }, + '&:hover': { color: colors.gray[700] }, }} > mcp @@ -100,7 +100,7 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr sx={{ color: semanticColors.mutedText, textDecoration: 'none', - '&:hover': { color: '#374151' }, + '&:hover': { color: colors.gray[700] }, }} > legal diff --git a/app/src/components/Header.tsx b/app/src/components/Header.tsx index ca0aa2eaf2..84e9b7cbb4 100644 --- a/app/src/components/Header.tsx +++ b/app/src/components/Header.tsx @@ -7,7 +7,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 { semanticColors } from '../theme'; +import { colors, typography, semanticColors } from '../theme'; interface HeaderProps { stats?: { specs: number; plots: number; libraries: number } | null; @@ -98,7 +98,7 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) { component="h1" sx={{ fontWeight: 700, - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', + fontFamily: typography.fontFamily, mb: { xs: 2, md: 3 }, letterSpacing: '-0.02em', fontSize: { xs: '2rem', sm: '2.75rem', md: '3.75rem' }, @@ -112,9 +112,9 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) { onKeyDown={handleLogoKeyDown} sx={{ cursor: 'pointer', userSelect: 'none', '&:focus': { outline: 'none' } }} > - py - plots - .ai + py + plots + .ai {onRandom && ( setTooltipOpen(true)} onMouseLeave={() => { if (!pinned) setTooltipOpen(false); }} sx={{ - color: tooltipOpen ? '#FFD43B' : '#3776AB', + color: tooltipOpen ? colors.accent : colors.primary, cursor: 'pointer', fontSize: '0.65rem', ml: 0.25, - '&:hover': { color: '#FFD43B' }, + '&:hover': { color: colors.accent }, }} > ✦ @@ -227,7 +227,7 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) { = { @@ -202,7 +202,7 @@ export const ImageCard = memo(function ImageCard({ px: 1.5, py: 0.5, borderRadius: 1, - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: fontSize.sm, pointerEvents: 'none', zIndex: 2, @@ -224,7 +224,7 @@ export const ImageCard = memo(function ImageCard({ opacity: 0, transition: 'opacity 0.2s, color 0.2s', '.MuiCard-root:hover &': { opacity: 1 }, - '&:hover': { bgcolor: 'rgba(255,255,255,1)', color: '#3776AB' }, + '&:hover': { bgcolor: 'rgba(255,255,255,1)', color: colors.primary }, }} > {copyState === 'loading' ? ( @@ -251,7 +251,7 @@ export const ImageCard = memo(function ImageCard({ tooltip: { sx: { maxWidth: { xs: '80vw', sm: 400 }, - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', + fontFamily: typography.fontFamily, fontSize: labelFontSize, }, }, @@ -267,12 +267,12 @@ export const ImageCard = memo(function ImageCard({ fontSize: labelFontSize, letterSpacing: labelLetterSpacing, fontWeight: 600, - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - color: isSpecTooltipOpen ? '#3776AB' : semanticColors.labelText, + fontFamily: typography.fontFamily, + color: isSpecTooltipOpen ? colors.primary : semanticColors.labelText, textTransform: 'lowercase', cursor: 'pointer', '&:hover': { - color: '#3776AB', + color: colors.primary, }, }} > @@ -282,7 +282,7 @@ export const ImageCard = memo(function ImageCard({ {showLibrary && ( <> - · + · {/* Clickable Library */} diff --git a/app/src/components/ImageLightbox.tsx b/app/src/components/ImageLightbox.tsx deleted file mode 100644 index f8e5394502..0000000000 --- a/app/src/components/ImageLightbox.tsx +++ /dev/null @@ -1,312 +0,0 @@ -/** - * ImageLightbox component - Full-resolution plot image viewer. - * - * MUI Modal-based lightbox with library switching, keyboard navigation, - * and action buttons (copy code, download, open interactive). - */ - -import { useEffect, useMemo, useCallback } from 'react'; -import Modal from '@mui/material/Modal'; -import Fade from '@mui/material/Fade'; -import Box from '@mui/material/Box'; -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; -import CloseIcon from '@mui/icons-material/Close'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import CheckIcon from '@mui/icons-material/Check'; -import DownloadIcon from '@mui/icons-material/Download'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; - -import type { Implementation } from '../types'; -import { fontSize } from '../theme'; -import { buildDetailSrcSet, DETAIL_SIZES } from '../utils/responsiveImage'; - -interface ImageLightboxProps { - open: boolean; - specId: string; - specTitle: string; - currentImpl: Implementation | null; - implementations: Implementation[]; - codeCopied: string | null; - onClose: () => void; - onSelectLibrary: (libraryId: string) => void; - onCopyCode: (impl: Implementation) => void; - onDownload: (impl: Implementation) => void; - onTrackEvent: (event: string, props?: Record) => void; -} - -export function ImageLightbox({ - open, - specId, - specTitle, - currentImpl, - implementations, - codeCopied, - onClose, - onSelectLibrary, - onCopyCode, - onDownload, - onTrackEvent, -}: ImageLightboxProps) { - // Sort implementations alphabetically for consistent ordering - const sortedImpls = useMemo( - () => [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)), - [implementations], - ); - - const currentIndex = useMemo(() => { - if (!currentImpl) return 0; - const idx = sortedImpls.findIndex((impl) => impl.library_id === currentImpl.library_id); - return idx >= 0 ? idx : 0; - }, [sortedImpls, currentImpl]); - - const navigatePrev = useCallback(() => { - if (sortedImpls.length < 2) return; - const prev = (currentIndex - 1 + sortedImpls.length) % sortedImpls.length; - onSelectLibrary(sortedImpls[prev].library_id); - }, [sortedImpls, currentIndex, onSelectLibrary]); - - const navigateNext = useCallback(() => { - if (sortedImpls.length < 2) return; - const next = (currentIndex + 1) % sortedImpls.length; - onSelectLibrary(sortedImpls[next].library_id); - }, [sortedImpls, currentIndex, onSelectLibrary]); - - // Keyboard navigation: left/right arrows switch libraries - useEffect(() => { - if (!open) return; - - const handleKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return; - - if (e.key === 'ArrowLeft') { - e.preventDefault(); - navigatePrev(); - } else if (e.key === 'ArrowRight') { - e.preventDefault(); - navigateNext(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [open, navigatePrev, navigateNext]); - - if (!currentImpl) return null; - - return ( - - - - {/* Close button (top-right) */} - - - - - {/* Left arrow */} - {sortedImpls.length > 1 && ( - - - - )} - - {/* Right arrow */} - {sortedImpls.length > 1 && ( - - - - )} - - {/* Full-resolution image */} - {currentImpl.preview_url && ( - - - - ) => { - const target = e.target as HTMLImageElement; - if (!target.dataset.fallback) { - target.dataset.fallback = '1'; - target.closest('picture')?.querySelectorAll('source').forEach((s) => s.remove()); - target.removeAttribute('srcset'); - target.src = currentImpl.preview_url!; - } - }} - /> - - )} - - {/* Bottom bar: library name + action buttons */} - - - {currentImpl.library_id} - - - - {currentImpl.code && ( - - onCopyCode(currentImpl)} - aria-label="Copy code" - size="small" - sx={{ - color: '#fff', - '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, - }} - > - {codeCopied === currentImpl.library_id ? ( - - ) : ( - - )} - - - )} - - - onDownload(currentImpl)} - aria-label="Download PNG" - size="small" - sx={{ - color: '#fff', - '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, - }} - > - - - - - {currentImpl.preview_html && ( - - - onTrackEvent('open_interactive', { - spec: specId, - library: currentImpl.library_id, - }) - } - aria-label="Open interactive" - size="small" - sx={{ - color: '#fff', - '&:hover': { bgcolor: 'rgba(255,255,255,0.15)' }, - }} - > - - - - )} - - - - - - ); -} diff --git a/app/src/components/ImagesGrid.tsx b/app/src/components/ImagesGrid.tsx index 9c14d82c43..b15154cc3d 100644 --- a/app/src/components/ImagesGrid.tsx +++ b/app/src/components/ImagesGrid.tsx @@ -6,6 +6,7 @@ import { ImageCard } from './ImageCard'; import { LoaderSpinner } from './LoaderSpinner'; import type { PlotImage, LibraryInfo, SpecInfo } from '../types'; import type { ImageSize } from '../constants'; +import { colors } from '../theme'; interface ImagesGridProps { images: PlotImage[]; @@ -107,9 +108,9 @@ export function ImagesGrid({ sx={{ maxWidth: 400, mx: 'auto', - bgcolor: '#f9fafb', - border: '1px solid #e5e7eb', - '& .MuiAlert-icon': { color: '#9ca3af' }, + bgcolor: colors.gray[50], + border: `1px solid ${colors.gray[200]}`, + '& .MuiAlert-icon': { color: colors.gray[500] }, }} > No images found for this spec. diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index cd9a9a3990..562c4d01e2 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async'; import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; +import { colors } from '../theme'; import { API_URL } from '../constants'; import type { LibraryInfo, SpecInfo } from '../types'; import { @@ -81,7 +82,7 @@ export function Layout() { - + diff --git a/app/src/components/LibraryPills.tsx b/app/src/components/LibraryPills.tsx index 7867e0d26f..ff740a39a7 100644 --- a/app/src/components/LibraryPills.tsx +++ b/app/src/components/LibraryPills.tsx @@ -3,7 +3,7 @@ import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import { fontSize, semanticColors } from '../theme'; +import { colors, fontSize, semanticColors, typography } from '../theme'; // Library abbreviations (same as filter display) const LIBRARY_ABBREV: Record = { @@ -99,7 +99,7 @@ export const LibraryPills = memo(function LibraryPills({ size="small" sx={{ color: semanticColors.mutedText, - '&:hover': { color: '#3776AB', bgcolor: '#f3f4f6' }, + '&:hover': { color: colors.primary, bgcolor: colors.gray[100] }, }} > @@ -126,19 +126,19 @@ export const LibraryPills = memo(function LibraryPills({ px: 1.5, py: 0.5, borderRadius: 2, - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: fontSize.base, fontWeight: isCenter ? 600 : 400, - bgcolor: '#f3f4f6', - border: isCenter ? '1px solid #3776AB' : '1px solid transparent', - color: isCenter ? '#374151' : semanticColors.mutedText, + bgcolor: colors.gray[100], + border: isCenter ? `1px solid ${colors.primary}` : '1px solid transparent', + color: isCenter ? colors.gray[700] : semanticColors.mutedText, cursor: 'pointer', transition: 'all 0.2s ease', whiteSpace: 'nowrap', '&:hover': { - bgcolor: '#e5e7eb', - borderColor: '#3776AB', - color: '#374151', + bgcolor: colors.gray[200], + borderColor: colors.primary, + color: colors.gray[700], }, }} > @@ -161,7 +161,7 @@ export const LibraryPills = memo(function LibraryPills({ size="small" sx={{ color: semanticColors.mutedText, - '&:hover': { color: '#3776AB', bgcolor: '#f3f4f6' }, + '&:hover': { color: colors.primary, bgcolor: colors.gray[100] }, }} > diff --git a/app/src/components/LoaderSpinner.tsx b/app/src/components/LoaderSpinner.tsx index db2e5020db..6b61cb2707 100644 --- a/app/src/components/LoaderSpinner.tsx +++ b/app/src/components/LoaderSpinner.tsx @@ -1,4 +1,5 @@ import Box from '@mui/material/Box'; +import { colors } from '../theme'; interface LoaderSpinnerProps { size?: 'large' | 'small'; @@ -23,8 +24,8 @@ export function LoaderSpinner({ size = 'large' }: LoaderSpinnerProps) { width: dotSize, height: dotSize, borderRadius: '50%', - background: '#3776AB', - boxShadow: `${offset}px 0 #3776AB`, + background: colors.primary, + boxShadow: `${offset}px 0 ${colors.primary}`, left: 0, top: 0, animation: 'ballMoveX 2s linear infinite', @@ -35,7 +36,7 @@ export function LoaderSpinner({ size = 'large' }: LoaderSpinnerProps) { width: dotSize, height: dotSize, borderRadius: '50%', - background: '#3776AB', + background: colors.primary, left: 0, top: 0, transform: `translateX(${doubleOffset}px) scale(1)`, @@ -45,23 +46,23 @@ export function LoaderSpinner({ size = 'large' }: LoaderSpinnerProps) { '@keyframes trfLoader': { '0%, 5%': { transform: `translateX(${doubleOffset}px) scale(1)`, - background: '#3776AB', + background: colors.primary, }, '10%': { transform: `translateX(${doubleOffset}px) scale(1)`, - background: '#FFD43B', + background: colors.accent, }, '40%': { transform: `translateX(${offset}px) scale(1.5)`, - background: '#FFD43B', + background: colors.accent, }, '90%, 95%': { transform: 'translateX(0px) scale(1)', - background: '#FFD43B', + background: colors.accent, }, '100%': { transform: 'translateX(0px) scale(1)', - background: '#3776AB', + background: colors.primary, }, }, '@keyframes ballMoveX': { diff --git a/app/src/components/PlotOfTheDay.tsx b/app/src/components/PlotOfTheDay.tsx index fdb72adce3..f36f0e6294 100644 --- a/app/src/components/PlotOfTheDay.tsx +++ b/app/src/components/PlotOfTheDay.tsx @@ -5,9 +5,10 @@ import Typography from '@mui/material/Typography'; import Link from '@mui/material/Link'; import IconButton from '@mui/material/IconButton'; import CloseIcon from '@mui/icons-material/Close'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import { API_URL } from '../constants'; -import { fontSize, semanticColors } from '../theme'; +import { API_URL, GITHUB_URL } from '../constants'; +import { colors, typography, fontSize, semanticColors } from '../theme'; import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; interface PlotOfTheDayData { @@ -19,10 +20,12 @@ interface PlotOfTheDayData { quality_score: number; preview_url: string | null; image_description: string | null; + library_version: string | null; + python_version: string | null; date: string; } -const mono = '"MonoLisa", "MonoLisa Fallback", monospace'; +const mono = typography.fontFamily; export function PlotOfTheDay() { const [data, setData] = useState(null); @@ -45,82 +48,187 @@ export function PlotOfTheDay() { if (!data || dismissed) return null; return ( - - - - - {/* Preview image */} - {data.preview_url && ( - - - - - + + {/* Top bar — full width terminal prompt */} + + $ + e.stopPropagation()} + sx={{ + fontFamily: mono, fontSize: fontSize.xxs, color: semanticColors.mutedText, + flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', + textDecoration: 'none', + '&:hover': { color: colors.primary }, + }} + > + python plots/{data.spec_id}/{data.library_id}.py + + + + + + + {/* Middle — image left, info right */} + + {/* Image */} + + {data.preview_url && ( + + + + + + )} + + + {/* Info */} + + {/* Label */} + + + + plot of the day + + + + {/* Title */} + + > + + {data.spec_title} + + + + {/* Description */} + {data.image_description && ( + + “{data.image_description.trim()}” + + )} - - )} + - {/* Info */} - - - - plot of the day + {/* Bottom bar — terminal output style */} + + + >>> - - {data.date} + + plot.png saved - - - - - {data.spec_title} + + + │ - - - - {data.library_name} · {data.quality_score}/100 - - - {data.image_description && ( - - "{data.image_description.trim()}" + + {data.library_name}{data.library_version && data.library_version !== 'unknown' ? ` ${data.library_version}` : ''} · Python {data.python_version || '3.13'} - )} + ); diff --git a/app/src/components/RelatedSpecs.tsx b/app/src/components/RelatedSpecs.tsx index da1685c82b..09869e1899 100644 --- a/app/src/components/RelatedSpecs.tsx +++ b/app/src/components/RelatedSpecs.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; @@ -7,9 +7,9 @@ import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import { API_URL } from '../constants'; +import { API_URL, LIB_ABBREV } from '../constants'; import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; -import { fontSize, semanticColors } from '../theme'; +import { colors, fontSize, semanticColors, typography } from '../theme'; interface RelatedSpec { id: string; @@ -20,19 +20,8 @@ interface RelatedSpec { shared_tags: string[]; } -const mono = '"MonoLisa", "MonoLisa Fallback", monospace'; +const mono = typography.fontFamily; -const LIB_ABBREV: Record = { - matplotlib: 'mpl', - seaborn: 'sns', - plotly: 'ply', - bokeh: 'bok', - altair: 'alt', - plotnine: 'p9', - pygal: 'pyg', - highcharts: 'hc', - letsplot: 'lp', -}; // 6 columns max at md+, ~160px each → 400w is plenty const SIZES = '(max-width: 599px) 50vw, (max-width: 899px) 33vw, 17vw'; @@ -50,6 +39,13 @@ interface RelatedSpecsProps { export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: RelatedSpecsProps) { const [related, setRelated] = useState([]); const [expanded, setExpanded] = useState(false); + const prevSpecIdRef = useRef(specId); + + // Reset expanded when specId changes (no effect needed) + if (prevSpecIdRef.current !== specId) { + prevSpecIdRef.current = specId; + setExpanded(false); + } useEffect(() => { let cancelled = false; @@ -62,16 +58,12 @@ export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: Re return () => { cancelled = true; }; }, [specId, mode, library]); - useEffect(() => { - setExpanded(false); - }, [specId]); - if (related.length === 0) return null; // Collapsed: CSS hides extra rows via gridAutoRows:0 + overflow:hidden return ( - + {spec.preview_url ? ( @@ -134,17 +126,17 @@ export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: Re /> ) : ( - - no preview + + no preview )} - + {spec.title} - {spec.shared_tags.length} common + {spec.shared_tags.length} tags in common {mode === 'full' && spec.library_id && ( diff --git a/app/src/components/SpecDetailView.tsx b/app/src/components/SpecDetailView.tsx index 0e50a4403f..e3ee2e355d 100644 --- a/app/src/components/SpecDetailView.tsx +++ b/app/src/components/SpecDetailView.tsx @@ -4,7 +4,7 @@ * Shows large image with library carousel and action buttons. */ -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; @@ -15,7 +15,7 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import type { Implementation } from '../types'; -import { fontSize } from '../theme'; +import { colors, fontSize, typography } from '../theme'; import { buildDetailSrcSet, DETAIL_SIZES } from '../utils/responsiveImage'; interface SpecDetailViewProps { @@ -56,11 +56,14 @@ export function SpecDetailView({ const [zoomed, setZoomed] = useState(false); const [origin, setOrigin] = useState({ x: 50, y: 50 }); const [animating, setAnimating] = useState(false); + const prevLibRef = useRef(selectedLibrary); - useEffect(() => { + // Reset zoom when library changes (no effect needed) + if (prevLibRef.current !== selectedLibrary) { + prevLibRef.current = selectedLibrary; setZoomed(false); setOrigin({ x: 50, y: 50 }); - }, [selectedLibrary]); + } const handleZoomToggle = useCallback( (e: React.MouseEvent) => { @@ -197,7 +200,7 @@ export function SpecDetailView({ px: 1.5, py: 0.5, borderRadius: 1, - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: fontSize.sm, pointerEvents: 'none', zIndex: 2, @@ -224,7 +227,7 @@ export function SpecDetailView({ aria-label="Copy code" sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff', color: '#3776AB' }, + '&:hover': { bgcolor: '#fff', color: colors.primary }, }} size="medium" > @@ -239,7 +242,7 @@ export function SpecDetailView({ aria-label="Download PNG" sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff', color: '#3776AB' }, + '&:hover': { bgcolor: '#fff', color: colors.primary }, }} size="medium" > @@ -259,7 +262,7 @@ export function SpecDetailView({ }} sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff', color: '#3776AB' }, + '&:hover': { bgcolor: '#fff', color: colors.primary }, }} size="medium" > @@ -282,7 +285,7 @@ export function SpecDetailView({ bgcolor: 'rgba(0,0,0,0.6)', borderRadius: 1, fontSize: '0.75rem', - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, color: '#fff', opacity: 0, transition: 'opacity 0.2s', diff --git a/app/src/components/SpecOverview.tsx b/app/src/components/SpecOverview.tsx index fbd5162f12..b32599c953 100644 --- a/app/src/components/SpecOverview.tsx +++ b/app/src/components/SpecOverview.tsx @@ -18,7 +18,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import type { Implementation } from '../types'; import { buildSrcSet, OVERVIEW_SIZES } from '../utils/responsiveImage'; -import { fontSize, semanticColors } from '../theme'; +import { colors, fontSize, semanticColors, typography } from '../theme'; interface LibraryMeta { id: string; @@ -197,7 +197,7 @@ function ImplementationCard({ px: 1.5, py: 0.5, borderRadius: 1, - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: fontSize.sm, pointerEvents: 'none', zIndex: 2, @@ -226,7 +226,7 @@ function ImplementationCard({ aria-label="Copy code" sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff', color: '#3776AB' }, + '&:hover': { bgcolor: '#fff', color: colors.primary }, }} size="medium" > @@ -239,7 +239,7 @@ function ImplementationCard({ aria-label="Download PNG" sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff', color: '#3776AB' }, + '&:hover': { bgcolor: '#fff', color: colors.primary }, }} size="medium" > @@ -258,7 +258,7 @@ function ImplementationCard({ }} sx={{ bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff', color: '#3776AB' }, + '&:hover': { bgcolor: '#fff', color: colors.primary }, }} size="medium" > @@ -298,7 +298,7 @@ function ImplementationCard({ alignItems: 'center', gap: 0.5, fontSize: fontSize.xs, - color: '#90caf9', + color: colors.tooltipLight, textDecoration: 'underline', '&:hover': { color: '#fff' }, }} @@ -319,7 +319,7 @@ function ImplementationCard({ tooltip: { sx: { maxWidth: { xs: '80vw', sm: 400 }, - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: fontSize.md, }, }, @@ -333,11 +333,11 @@ function ImplementationCard({ sx={{ fontSize: fontSize.md, fontWeight: 600, - fontFamily: '"MonoLisa", monospace', - color: isTooltipOpen ? '#3776AB' : semanticColors.labelText, + fontFamily: typography.fontFamily, + color: isTooltipOpen ? colors.primary : semanticColors.labelText, textTransform: 'lowercase', cursor: 'pointer', - '&:hover': { color: '#3776AB' }, + '&:hover': { color: colors.primary }, }} > {impl.library_id} @@ -352,7 +352,7 @@ function ImplementationCard({ sx={{ fontSize: fontSize.md, fontWeight: 600, - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, color: semanticColors.labelText, }} > diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx index 9b104c50f5..d844928097 100644 --- a/app/src/components/SpecTabs.tsx +++ b/app/src/components/SpecTabs.tsx @@ -17,7 +17,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; const CodeHighlighter = lazy(() => import('./CodeHighlighter')); -import { fontSize, semanticColors } from '../theme'; +import { colors, fontSize, semanticColors, typography } from '../theme'; import { API_URL } from '../constants'; @@ -95,10 +95,10 @@ function MdHeading({ level, children }: { level: 1 | 2; children: React.ReactNod @@ -190,7 +190,7 @@ export function SpecTabs({ // Fetch global tag counts once (module-level cache) useEffect(() => { - if (cachedTagCounts) { setTagCounts(cachedTagCounts); return; } + if (cachedTagCounts) return; fetch(`${API_URL}/plots/filter?limit=1`) .then(r => r.ok ? r.json() : null) .then(data => { @@ -252,7 +252,7 @@ export function SpecTabs({ // Lazy-loaded syntax highlighter - only loads when Code tab is opened const highlightedCode = code ? ( + {code} }> @@ -286,22 +286,22 @@ export function SpecTabs({ variant="fullWidth" sx={{ '& .MuiTab-root': { - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, textTransform: 'none', fontSize: '0.875rem', minHeight: 48, transition: 'background-color 0.15s ease, color 0.15s ease', borderRadius: '4px 4px 0 0', '&:hover': { - backgroundColor: '#f3f4f6', - color: '#3776AB', + backgroundColor: colors.gray[100], + color: colors.primary, }, }, '& .Mui-selected': { - color: '#3776AB', + color: colors.primary, }, '& .MuiTabs-indicator': { - backgroundColor: '#3776AB', + backgroundColor: colors.primary, }, }} > @@ -315,7 +315,7 @@ export function SpecTabs({ {!overviewMode && ( tabIndex === 3 && handleTabChange(e, 3)} - icon={} + icon={} iconPosition="start" label={qualityScore ? `${Math.round(qualityScore)}` : 'Quality'} /> @@ -346,7 +346,7 @@ export function SpecTabs({ {/* Title only - spec ID visible in breadcrumb */} @@ -385,9 +385,9 @@ export function SpecTabs({ Description @@ -438,10 +438,10 @@ export function SpecTabs({ {/* Image Description */} @@ -450,9 +450,9 @@ export function SpecTabs({ Description @@ -471,13 +471,13 @@ export function SpecTabs({ key={i} component="li" sx={{ - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: '0.85rem', - color: '#4b5563', + color: semanticColors.labelText, lineHeight: 1.7, ml: 2, mb: 0.25, - '&::marker': { color: '#22c55e' }, + '&::marker': { color: colors.success }, }} > {s} @@ -497,13 +497,13 @@ export function SpecTabs({ key={i} component="li" sx={{ - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: '0.85rem', - color: '#4b5563', + color: semanticColors.labelText, lineHeight: 1.7, ml: 2, mb: 0.25, - '&::marker': { color: '#ef4444' }, + '&::marker': { color: colors.error }, }} > {w} @@ -515,13 +515,13 @@ export function SpecTabs({ {/* No data message */} {!imageDescription && (!strengths || strengths.length === 0) && (!weaknesses || weaknesses.length === 0) && ( - + No implementation review data available. )} {/* Metadata */} - + {specId} {libraryId && ` · ${libraryId}`} {(() => { @@ -538,20 +538,20 @@ export function SpecTabs({ {/* Score */} Score = 90 ? '#22c55e' : qualityScore && qualityScore >= 70 ? '#f59e0b' : '#ef4444', + color: qualityScore && qualityScore >= 90 ? colors.success : qualityScore && qualityScore >= 70 ? colors.warning : colors.error, }} > {qualityScore ? `${Math.round(qualityScore)}/100` : 'N/A'} @@ -587,26 +587,26 @@ export function SpecTabs({ {items.length > 0 && ( isExpanded ? ( - + ) : ( - + ) )} - + {category.replace(/_/g, ' ')} - + {score}/{max} {/* Progress bar */} - + = 90 ? '#22c55e' : pct >= 70 ? '#f59e0b' : '#ef4444', + bgcolor: pct >= 90 ? colors.success : pct >= 70 ? colors.warning : colors.error, borderRadius: 2, }} /> @@ -625,26 +625,26 @@ export function SpecTabs({ borderRadius: '50%', bgcolor: item.score === 0 - ? '#ef4444' // red for 0 + ? colors.error : item.score === item.max - ? '#22c55e' // green for full - : '#f59e0b', // yellow for partial + ? colors.success + : colors.warning, }} /> - + {item.name} - + {item.score}/{item.max} {item.comment && ( + No quality data available. )} @@ -680,7 +680,7 @@ export function SpecTabs({ const paramName = SPEC_TAG_PARAM_MAP[category]; return ( - + {category.replace(/_/g, ' ')}: {values.map((value, i) => { @@ -690,13 +690,13 @@ export function SpecTabs({ handleTagClick(paramName, value) : undefined} sx={{ - fontFamily: '"MonoLisa", monospace', fontSize: fontSize.xs, height: 24, - bgcolor: isHighlighted ? '#dbeafe' : '#f3f4f6', - color: isHighlighted ? '#1e40af' : '#4b5563', + fontFamily: typography.fontFamily, fontSize: fontSize.xs, height: 24, + bgcolor: isHighlighted ? colors.highlight.bg : colors.gray[100], + color: isHighlighted ? colors.highlight.text : semanticColors.labelText, cursor: paramName ? 'pointer' : 'default', transition: 'all 0.2s ease', fontWeight: isHighlighted ? 600 : 400, - '&:hover': paramName ? { bgcolor: '#e5e7eb' } : {}, + '&:hover': paramName ? { bgcolor: colors.gray[200] } : {}, }} /> ); @@ -713,7 +713,7 @@ export function SpecTabs({ const paramName = IMPL_TAG_PARAM_MAP[category]; return ( - + {category}: {values.map((value, i) => { @@ -723,13 +723,13 @@ export function SpecTabs({ handleTagClick(paramName, value) : undefined} sx={{ - fontFamily: '"MonoLisa", monospace', fontSize: fontSize.xs, height: 24, - bgcolor: isHighlighted ? '#dbeafe' : '#f3f4f6', - color: isHighlighted ? '#1e40af' : '#4b5563', + fontFamily: typography.fontFamily, fontSize: fontSize.xs, height: 24, + bgcolor: isHighlighted ? colors.highlight.bg : colors.gray[100], + color: isHighlighted ? colors.highlight.text : semanticColors.labelText, cursor: paramName ? 'pointer' : 'default', transition: 'all 0.2s ease', fontWeight: isHighlighted ? 600 : 400, - '&:hover': paramName ? { bgcolor: '#e5e7eb' } : {}, + '&:hover': paramName ? { bgcolor: colors.gray[200] } : {}, }} /> ); diff --git a/app/src/components/ToolbarActions.tsx b/app/src/components/ToolbarActions.tsx index 2b8e1e6cd1..abdcbdb28b 100644 --- a/app/src/components/ToolbarActions.tsx +++ b/app/src/components/ToolbarActions.tsx @@ -11,7 +11,7 @@ import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ListIcon from '@mui/icons-material/List'; import type { ImageSize } from '../constants'; -import { semanticColors } from '../theme'; +import { colors, semanticColors } from '../theme'; interface ToolbarActionsProps { imageSize: ImageSize; @@ -35,7 +35,7 @@ export function CatalogLink() { width: 36, height: 36, color: semanticColors.mutedText, - '&:hover': { color: '#3776AB' }, + '&:hover': { color: colors.primary }, }} > @@ -77,8 +77,8 @@ export function GridSizeToggle({ imageSize, onImageSizeChange, onTrackEvent }: T height: 36, cursor: 'pointer', color: semanticColors.mutedText, - '&:hover': { color: '#3776AB' }, - '&:focus-visible': { outline: '2px solid #3776AB', outlineOffset: 2 }, + '&:hover': { color: colors.primary }, + '&:focus-visible': { outline: `2px solid ${colors.primary}`, outlineOffset: 2 }, }} > {imageSize === 'normal' ? ( diff --git a/app/src/constants/index.ts b/app/src/constants/index.ts index c4984b513d..33d57f2205 100644 --- a/app/src/constants/index.ts +++ b/app/src/constants/index.ts @@ -7,3 +7,16 @@ export const BATCH_SIZE = 36; // Image size: 'normal' or 'compact' (half size) export type ImageSize = 'normal' | 'compact'; + +// Library abbreviations for compact display +export const LIB_ABBREV: Record = { + matplotlib: 'mpl', + seaborn: 'sns', + plotly: 'ply', + bokeh: 'bok', + altair: 'alt', + plotnine: 'p9', + pygal: 'pyg', + highcharts: 'hc', + letsplot: 'lp', +}; diff --git a/app/src/main.tsx b/app/src/main.tsx index 352e839b3d..2b504ecce7 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -4,25 +4,26 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import { AppRouter } from './router'; import { reportWebVitals } from './analytics/reportWebVitals'; +import { typography, colors, semanticColors, fontSize } from './theme'; // Import MonoLisa font - hosted on GCS (all text uses MonoLisa) import './styles/fonts.css'; const theme = createTheme({ typography: { - fontFamily: '"MonoLisa", "MonoLisa Fallback", Consolas, Menlo, Monaco, monospace', + fontFamily: typography.fontFamily, }, palette: { mode: 'light', primary: { - main: '#3776AB', // Python blue + main: colors.primary, }, text: { - primary: '#1f2937', - secondary: '#52525b', + primary: colors.gray[800], + secondary: semanticColors.subtleText, }, background: { - default: '#fafafa', + default: colors.background, }, }, shape: { @@ -32,7 +33,7 @@ const theme = createTheme({ MuiCssBaseline: { styleOverrides: { body: { - backgroundColor: '#fafafa', + backgroundColor: colors.background, }, }, }, @@ -44,8 +45,8 @@ const theme = createTheme({ styleOverrides: { tooltip: { backgroundColor: 'rgba(0,0,0,0.8)', - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - fontSize: '0.75rem', + fontFamily: typography.fontFamily, + fontSize: fontSize.xs, padding: '4px 8px', borderRadius: 4, }, diff --git a/app/src/pages/CatalogPage.tsx b/app/src/pages/CatalogPage.tsx index ea1633c080..ac9642cd56 100644 --- a/app/src/pages/CatalogPage.tsx +++ b/app/src/pages/CatalogPage.tsx @@ -14,7 +14,7 @@ import { useAppData, useHomeState } from '../hooks'; import { Breadcrumb } from '../components/Breadcrumb'; import { Footer } from '../components/Footer'; import type { PlotImage } from '../types'; -import { fontSize, semanticColors } from '../theme'; +import { typography, colors, fontSize, semanticColors } from '../theme'; interface CatalogSpec { id: string; @@ -158,7 +158,7 @@ export function CatalogPage() { {/* Breadcrumb navigation */} suggest spec @@ -183,10 +183,10 @@ export function CatalogPage() { variant="h4" component="h1" sx={{ - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontWeight: 600, mb: 4, - color: '#1f2937', + color: colors.gray[800], }} > catalog @@ -298,7 +298,7 @@ export function CatalogPage() { bgcolor: 'rgba(0,0,0,0.6)', borderRadius: 1, fontSize: fontSize.xs, - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, color: '#fff', opacity: 0, transition: 'opacity 0.2s', @@ -320,7 +320,7 @@ export function CatalogPage() { bgcolor: 'rgba(0,0,0,0.6)', borderRadius: 0.5, fontSize: fontSize.xs, - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, color: '#fff', opacity: 0, transition: 'opacity 0.2s', @@ -346,12 +346,12 @@ export function CatalogPage() { > {spec.title} @@ -366,7 +366,7 @@ export function CatalogPage() { } }} sx={{ - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: fontSize.base, color: semanticColors.subtleText, lineHeight: 1.6, @@ -400,12 +400,12 @@ export function CatalogPage() { position: 'fixed', bottom: 24, right: 24, - bgcolor: '#f3f4f6', - color: '#6b7280', + bgcolor: colors.gray[100], + color: semanticColors.mutedText, opacity: showScrollTop ? 1 : 0, visibility: showScrollTop ? 'visible' : 'hidden', transition: 'opacity 0.3s, visibility 0.3s', - '&:hover': { bgcolor: '#e5e7eb', color: '#3776AB' }, + '&:hover': { bgcolor: colors.gray[200], color: colors.primary }, }} > diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx index 1deb8f49f1..4a926b48d9 100644 --- a/app/src/pages/DebugPage.tsx +++ b/app/src/pages/DebugPage.tsx @@ -28,6 +28,12 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import { API_URL, LIBRARIES } from '../constants'; import { Breadcrumb } from '../components/Breadcrumb'; +import { + typography, + colors, + semanticColors, + fontSize, +} from '../theme'; // ============================================================================ // Types @@ -94,16 +100,16 @@ type SortDir = 'asc' | 'desc'; // ============================================================================ const getScoreColor = (score: number | null): string => { - if (score === null) return '#d1d5db'; - if (score >= 90) return '#22c55e'; - if (score >= 50) return '#eab308'; - return '#ef4444'; + if (score === null) return colors.gray[300]; + if (score >= 90) return colors.success; + if (score >= 50) return colors.warning; + return colors.error; }; const ScoreCell = ({ score, specId, library }: { score: number | null; specId: string; library: string }) => { if (score === null) { return ( - - + - ); } return ( @@ -112,7 +118,7 @@ const ScoreCell = ({ score, specId, library }: { score: number | null; specId: s to={`/${specId}/${library}`} sx={{ display: 'block', textDecoration: 'none', textAlign: 'center', '&:hover': { opacity: 0.7 } }} > - + {Math.round(score)} @@ -139,14 +145,14 @@ const ProblemList = ({ items, title, icon }: { items: ProblemSpec[]; title: stri {item.id} - {item.issue} + {item.issue} {item.value && ( - + {item.value} )} @@ -276,10 +282,10 @@ export function DebugPage() { } return ( - + {/* Breadcrumb */} @@ -314,16 +320,16 @@ export function DebugPage() { {data.system.database_connected ? ( - + ) : ( - + )} - Database + Database - + Response: {data.system.api_response_time_ms.toFixed(0)}ms - + Updated: {new Date(data.system.timestamp).toLocaleTimeString()} @@ -338,18 +344,18 @@ export function DebugPage() { elevation={0} sx={{ p: 1.5, - bgcolor: '#f9fafb', - border: '1px solid #e5e7eb', + bgcolor: colors.gray[50], + border: `1px solid ${colors.gray[200]}`, borderRadius: 1, minWidth: 100, }} > - {lib.name} - {lib.impl_count} - + {lib.name} + {lib.impl_count} + avg: {lib.avg_score?.toFixed(1) || '-'} - + {lib.min_score?.toFixed(0) || '-'} - {lib.max_score?.toFixed(0) || '-'} @@ -367,22 +373,22 @@ export function DebugPage() { } + icon={} /> } + icon={} /> } + icon={} /> } + icon={} /> )} @@ -399,11 +405,11 @@ export function DebugPage() { /> setShowIncomplete(e.target.checked)} />} - label={Incomplete ({'<'}9)} + label={Incomplete ({'<'}9)} /> setShowLowScores(e.target.checked)} />} - label={Low scores ({'<'}90)} + label={Low scores ({'<'}90)} /> Missing library @@ -421,21 +427,21 @@ export function DebugPage() { {/* Legend */} - + - + 90+ (excellent) - + 50-89 (acceptable) - + <50 (poor) - + - (missing) @@ -476,7 +482,7 @@ export function DebugPage() { {LIBRARIES.map((lib) => ( - + {lib.slice(0, 4)} ))} @@ -495,15 +501,15 @@ export function DebugPage() { {filteredSpecs.map((spec) => { const implCount = countImpls(spec); return ( - + 0 ? '#6b7280' : '#d1d5db', + fontFamily: typography.fontFamily, + color: implCount === 9 ? colors.success : implCount > 0 ? semanticColors.mutedText : colors.gray[300], }} > {implCount}/9 @@ -540,9 +546,9 @@ export function DebugPage() { @@ -555,7 +561,7 @@ export function DebugPage() { ))} - + {spec.updated ? new Date(spec.updated).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '-'} diff --git a/app/src/pages/HomePage.tsx b/app/src/pages/HomePage.tsx index d2f025b266..b0d1771869 100644 --- a/app/src/pages/HomePage.tsx +++ b/app/src/pages/HomePage.tsx @@ -15,6 +15,7 @@ import { FilterBar } from '../components/FilterBar'; import { ImagesGrid } from '../components/ImagesGrid'; import { PlotOfTheDay } from '../components/PlotOfTheDay'; import { useAppData, useHomeState } from '../hooks'; +import { colors } from '../theme'; export function HomePage() { const navigate = useNavigate(); @@ -228,12 +229,12 @@ export function HomePage() { position: 'fixed', bottom: 24, right: 24, - bgcolor: '#f3f4f6', - color: '#6b7280', + bgcolor: colors.gray[100], + color: colors.gray[500], opacity: showScrollTop ? 1 : 0, visibility: showScrollTop ? 'visible' : 'hidden', transition: 'opacity 0.3s, visibility 0.3s', - '&:hover': { bgcolor: '#e5e7eb', color: '#3776AB' }, + '&:hover': { bgcolor: colors.gray[200], color: colors.primary }, }} > diff --git a/app/src/pages/InteractivePage.tsx b/app/src/pages/InteractivePage.tsx index f9a88230a6..1a61c121bc 100644 --- a/app/src/pages/InteractivePage.tsx +++ b/app/src/pages/InteractivePage.tsx @@ -9,6 +9,7 @@ import CloseIcon from '@mui/icons-material/Close'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { API_URL } from '../constants'; +import { typography, colors, semanticColors } from '../theme'; import { useAnalytics } from '../hooks'; import { Breadcrumb } from '../components/Breadcrumb'; @@ -162,7 +163,7 @@ export function InteractivePage() { display: 'flex', alignItems: 'center', justifyContent: 'center', - bgcolor: '#fafafa', + bgcolor: colors.background, }} > @@ -180,13 +181,13 @@ export function InteractivePage() { flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - bgcolor: '#fafafa', - fontFamily: '"MonoLisa", monospace', - color: '#6b7280', + bgcolor: colors.background, + fontFamily: typography.fontFamily, + color: semanticColors.mutedText, }} > {error || 'Interactive plot not available'} - + @@ -217,7 +218,7 @@ export function InteractivePage() { {/* Breadcrumb navigation */} - + - + Legal Notice - + Privacy Policy - + Transparency @@ -105,7 +81,7 @@ export function LegalPage() { Contact
Email:{' '} - + admin@pyplots.ai
@@ -115,7 +91,7 @@ export function LegalPage() { target="_blank" rel="noopener noreferrer" onClick={() => trackEvent('external_link', { destination: 'linkedin' })} - sx={{ color: '#3776AB' }} + sx={{ color: colors.primary }} > markus-neusinger @@ -126,7 +102,7 @@ export function LegalPage() { target="_blank" rel="noopener noreferrer" onClick={() => trackEvent('external_link', { destination: 'x' })} - sx={{ color: '#3776AB' }} + sx={{ color: colors.primary }} > @MarkusNeusinger @@ -137,7 +113,7 @@ export function LegalPage() { target="_blank" rel="noopener noreferrer" onClick={() => trackEvent('external_link', { destination: 'github_personal' })} - sx={{ color: '#3776AB' }} + sx={{ color: colors.primary }} > MarkusNeusinger @@ -166,7 +142,7 @@ export function LegalPage() { What We Collect Anonymized Analytics: We use{' '} - + Plausible Analytics , a privacy-focused analytics tool. It collects no personal data, uses no cookies, and does not track you @@ -176,7 +152,7 @@ export function LegalPage() { Public Dashboard: Our analytics are{' '} - + fully public {' '} – see exactly what we see. @@ -184,7 +160,7 @@ export function LegalPage() { Server Logs: Technical server logs including IP addresses, request URLs, and user agents are retained for 30 days via{' '} - + Google Cloud Logging {' '} for security and debugging purposes. @@ -207,7 +183,7 @@ export function LegalPage() { Hosting & Third Parties All services are hosted in the EU (Netherlands, europe-west4): - +
Hosting @@ -232,7 +208,7 @@ export function LegalPage() { You have the right to access, rectify, erase, and export your data. Since we do not store personal data, there is typically nothing to delete or export. For questions, contact{' '} - + admin@pyplots.ai . @@ -250,12 +226,12 @@ export function LegalPage() { Technology Stack -
+
Editor - + JetBrains PyCharm @@ -263,19 +239,19 @@ export function LegalPage() { Frontend - + React {' '} 19,{' '} - + Vite ,{' '} - + MUI {' '} 7,{' '} - + TypeScript @@ -283,15 +259,15 @@ export function LegalPage() { Backend - + Python {' '} 3.13,{' '} - + FastAPI ,{' '} - + SQLAlchemy @@ -299,7 +275,7 @@ export function LegalPage() { Database - + PostgreSQL {' '} 18 @@ -308,7 +284,7 @@ export function LegalPage() { Hosting - + Google Cloud Run {' '} (Netherlands) @@ -317,7 +293,7 @@ export function LegalPage() { Storage - + Google Cloud Storage @@ -325,11 +301,11 @@ export function LegalPage() { Analytics - + Plausible {' '} (privacy-friendly, no cookies,{' '} - + public dashboard ) @@ -338,11 +314,11 @@ export function LegalPage() { Code Generation - + Anthropic Claude {' '} (code generation & review),{' '} - + GitHub Copilot {' '} (PR reviews) @@ -351,7 +327,7 @@ export function LegalPage() { Typography - + MonoLisa {' '} by Marcus Sterz @@ -364,13 +340,13 @@ export function LegalPage() { The entire codebase is publicly available under the MIT License:
- + github.com/MarkusNeusinger/pyplots
Monthly Hosting Costs (approximate) -
+
Cloud Run @@ -398,13 +374,13 @@ export function LegalPage() {
- + Direct hosting costs only. Subscriptions (GitHub Pro, Plausible, Claude MAX, PyCharm, etc.) are shared across projects. All costs are currently covered privately. - + Last updated: March 2026
diff --git a/app/src/pages/McpPage.tsx b/app/src/pages/McpPage.tsx index 80fd38227b..d73cf2cfee 100644 --- a/app/src/pages/McpPage.tsx +++ b/app/src/pages/McpPage.tsx @@ -14,6 +14,15 @@ import { useAnalytics } from '../hooks'; import { Breadcrumb } from '../components/Breadcrumb'; import { Footer } from '../components/Footer'; import { GITHUB_URL } from '../constants'; +import { + colors, + fontSize, + headingStyle, + subheadingStyle, + textStyle, + codeBlockStyle, + tableStyle, +} from '../theme'; export function McpPage() { const { trackPageview, trackEvent } = useAnalytics(); @@ -22,64 +31,6 @@ export function McpPage() { trackPageview('/mcp'); }, [trackPageview]); - const headingStyle = { - fontFamily: '"MonoLisa", monospace', - fontWeight: 600, - fontSize: '1.25rem', - color: '#1f2937', - mb: 2, - }; - - const subheadingStyle = { - fontFamily: '"MonoLisa", monospace', - fontWeight: 600, - fontSize: '1rem', - color: '#374151', - mt: 3, - mb: 1, - }; - - const textStyle = { - fontFamily: '"MonoLisa", monospace', - fontSize: '0.9rem', - color: '#4b5563', - lineHeight: 1.8, - mb: 2, - }; - - const codeBlockStyle = { - fontFamily: '"MonoLisa", monospace', - fontSize: '0.85rem', - backgroundColor: '#1e293b', - color: '#e2e8f0', - p: 2, - borderRadius: 1, - overflow: 'auto', - mb: 2, - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - }; - - const tableStyle = { - '& .MuiTableCell-root': { - fontFamily: '"MonoLisa", monospace', - fontSize: '0.85rem', - color: '#4b5563', - borderBottom: '1px solid #f3f4f6', - py: 1.5, - px: 2, - }, - '& .MuiTableCell-head': { - fontWeight: 600, - color: '#374151', - backgroundColor: '#f9fafb', - }, - '& .MuiTableCell-root:first-of-type': { - fontWeight: 500, - color: '#374151', - }, - }; - return ( <> @@ -96,22 +47,22 @@ export function McpPage() { - + - + What is MCP - + Configuration - + Available Tools - + Use Cases - + Resources @@ -125,7 +76,7 @@ export function McpPage() { The{' '} - + Model Context Protocol (MCP) {' '} is an open standard by Anthropic that enables AI assistants to securely connect to external data sources and tools. @@ -142,13 +93,7 @@ export function McpPage() { {item} @@ -157,7 +102,7 @@ export function McpPage() { Endpoint:{' '} - + https://api.pyplots.ai/mcp/ @@ -303,7 +248,7 @@ export function McpPage() { href={`${GITHUB_URL}/blob/main/docs/reference/mcp.md`} target="_blank" rel="noopener" - sx={{ color: '#3776AB' }} + sx={{ color: colors.primary }} > docs/reference/mcp.md @@ -312,7 +257,7 @@ export function McpPage() { MCP Official Website - + modelcontextprotocol.io diff --git a/app/src/pages/NotFoundPage.tsx b/app/src/pages/NotFoundPage.tsx index 3226dc1b7d..2f23acf5f5 100644 --- a/app/src/pages/NotFoundPage.tsx +++ b/app/src/pages/NotFoundPage.tsx @@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet-async'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import { Footer } from '../components/Footer'; +import { typography, colors, semanticColors } from '../theme'; export function NotFoundPage() { return ( @@ -15,17 +16,17 @@ export function NotFoundPage() { 404 - + page not found back to pyplots.ai diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index 4ef44a47fd..f9a278cab5 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -11,8 +11,8 @@ import BugReportIcon from '@mui/icons-material/BugReport'; import ListIcon from '@mui/icons-material/List'; import { NotFoundPage } from './NotFoundPage'; -import { API_URL, GITHUB_URL } from '../constants'; -import { fontSize, semanticColors } from '../theme'; +import { API_URL, GITHUB_URL, LIB_ABBREV } from '../constants'; +import { typography, colors, fontSize, semanticColors } from '../theme'; import { useAnalytics, useCodeFetch } from '../hooks'; import { useAppData } from '../hooks'; import { LibraryPills } from '../components/LibraryPills'; @@ -174,7 +174,7 @@ export function SpecPage() { console.error('Copy failed:', err); } }, - [specId, trackEvent, isOverviewMode] + [specId, trackEvent, isOverviewMode, fetchCode] ); // Build report issue URL @@ -244,10 +244,10 @@ export function SpecPage() { if (error) { return ( - + {error} - @@ -285,17 +285,17 @@ export function SpecPage() { items={ isOverviewMode ? [ - { label: 'pyplots.ai', to: '/' }, + { label: 'pyplots.ai', shortLabel: 'pp', to: '/' }, { label: specId || '' }, ] : [ - { label: 'pyplots.ai', to: '/' }, + { label: 'pyplots.ai', shortLabel: 'pp', to: '/' }, { label: specId || '', to: `/${specId}` }, - { label: selectedLibrary || '' }, + { label: selectedLibrary || '', shortLabel: LIB_ABBREV[selectedLibrary || ''] || selectedLibrary || '' }, ] } rightAction={ - + - + @@ -334,15 +334,15 @@ export function SpecPage() { textDecoration: 'none', display: 'flex', alignItems: 'center', - '&:hover': { color: '#3776AB' }, + '&:hover': { color: colors.primary }, }} > - + @@ -360,11 +360,11 @@ export function SpecPage() { component="h1" sx={{ textAlign: 'center', - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontWeight: 600, fontSize: { xs: '1.375rem', sm: '1.625rem', md: '2.125rem' }, mb: 1, - color: '#1f2937', + color: colors.gray[800], }} > {specData.title} @@ -375,9 +375,9 @@ export function SpecPage() { onClick={() => !descExpanded && setDescExpanded(true)} sx={{ textAlign: 'center', - fontFamily: '"MonoLisa", monospace', + fontFamily: typography.fontFamily, fontSize: { xs: '0.875rem', sm: '0.9375rem' }, - color: '#52525b', + color: semanticColors.subtleText, maxWidth: { xs: '100%', md: 800, lg: 950, xl: 1100 }, mx: 'auto', mb: 2, @@ -446,11 +446,11 @@ export function SpecPage() { {'< all implementations'} diff --git a/app/src/pages/StatsPage.tsx b/app/src/pages/StatsPage.tsx index 88be67543b..1ff7b22546 100644 --- a/app/src/pages/StatsPage.tsx +++ b/app/src/pages/StatsPage.tsx @@ -11,6 +11,13 @@ import { Breadcrumb } from '../components/Breadcrumb'; import { Footer } from '../components/Footer'; import { API_URL } from '../constants'; import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; +import { + typography, + colors, + semanticColors, + fontSize, + subheadingStyle, +} from '../theme'; interface LibraryStats { id: string; @@ -63,13 +70,11 @@ interface DashboardData { timeline: TimelinePoint[]; } -const mono = '"MonoLisa", "MonoLisa Fallback", monospace'; - function scoreColor(score: number | null): string { - if (score === null) return '#e5e7eb'; - if (score >= 90) return '#22c55e'; - if (score >= 75) return '#eab308'; - return '#ef4444'; + if (score === null) return colors.gray[200]; + if (score >= 90) return colors.success; + if (score >= 75) return colors.warning; + return colors.error; } @@ -97,17 +102,15 @@ export function StatsPage() { .finally(() => setLoading(false)); }, []); - const subheadingStyle = { fontFamily: mono, fontWeight: 600, fontSize: '0.95rem', color: '#374151', mt: 4, mb: 1.5 }; - if (loading) return ( - loading stats... + loading stats... ); if (error || !data) return ( - failed to load stats{error ? `: ${error}` : ''} + failed to load stats{error ? `: ${error}` : ''} ); @@ -121,7 +124,7 @@ export function StatsPage() { - + {/* Summary Counters */} @@ -133,28 +136,28 @@ export function StatsPage() { { label: 'avg quality', value: data.avg_quality_score, suffix: '' }, { label: 'coverage', value: data.coverage_percent, suffix: '%' }, ].map(item => ( - - + + {typeof item.value === 'number' ? `${formatNum(item.value)}${item.suffix}` : '—'} - + {item.label} ))} - + visitor analytics at{' '} trackEvent('external_link', { destination: 'plausible' })} - sx={{ color: '#9ca3af', textDecoration: 'none', '&:hover': { color: '#306998' } }} + sx={{ color: semanticColors.mutedText, textDecoration: 'none', '&:hover': { color: colors.primaryDark } }} >plausible.io/pyplots.ai {/* Library Stats — dual mini histograms per library */} libraries {/* Quality distribution per library */} - + quality distribution 50–100 · count · avg @@ -167,7 +170,7 @@ export function StatsPage() { const maxBucket = Math.max(...allBuckets.map(([, c]) => c), 1); return ( - + {lib.name} @@ -186,7 +189,7 @@ export function StatsPage() { ); })} - + {lib.impl_count} · {lib.avg_score ?? '—'} @@ -195,7 +198,7 @@ export function StatsPage() { {/* LOC distribution per library */} - + lines of code per implementation 0–400+ · avg @@ -205,7 +208,7 @@ export function StatsPage() { const maxLoc = Math.max(...locBuckets.map(([, c]) => c), 1); return ( - + {lib.name} @@ -214,14 +217,14 @@ export function StatsPage() { 0 ? `${Math.max((count / maxLoc) * 100, 15)}%` : 0, - bgcolor: '#306998', + bgcolor: colors.primaryDark, opacity: 0.4, borderRadius: '1px 1px 0 0', }} /> ))} - + avg {lib.avg_loc?.toFixed(0) ?? '—'} @@ -231,7 +234,7 @@ export function StatsPage() { {/* Coverage dot matrix — right after libraries */} coverage - + {data.coverage_percent}% · {data.total_implementations} of {data.total_specs * 9} possible @@ -245,9 +248,9 @@ export function StatsPage() { to={`/${row.spec_id}`} sx={{ display: 'block', width: 10, height: 10, borderRadius: '2px', - bgcolor: count === 0 ? '#f3f4f6' : `rgba(34, 197, 94, ${0.15 + intensity * 0.7})`, + bgcolor: count === 0 ? colors.gray[100] : `rgba(34, 197, 94, ${0.15 + intensity * 0.7})`, textDecoration: 'none', - '&:hover': { outline: '1px solid #22c55e' }, + '&:hover': { outline: `1px solid ${colors.success}` }, }} />
@@ -255,11 +258,11 @@ export function StatsPage() { })}
- less + less {[0, 0.25, 0.5, 0.75, 1].map(v => ( ))} - more + more {/* Timeline */} @@ -272,7 +275,7 @@ export function StatsPage() { - + {data.timeline.slice(-24)[0]?.month ?? data.timeline[0]?.month} - + {data.timeline[data.timeline.length - 1]?.month} @@ -304,7 +307,7 @@ export function StatsPage() { sx={{ textDecoration: 'none', color: 'inherit', '&:hover': { opacity: 0.85 }, transition: 'opacity 0.15s ease' }} onClick={() => trackEvent('stats_top_impl_click', { spec: impl.spec_id, library: impl.library_id })} > - + {impl.preview_url ? ( @@ -315,18 +318,18 @@ export function StatsPage() { /> ) : ( - + )} - + {impl.spec_title} - + {impl.library_id} - + {impl.quality_score} @@ -348,12 +351,12 @@ export function StatsPage() { const entries = Object.entries(values); return ( - + {category.replace('_', ' ')} {entries.slice(0, 20).map(([tag, count]) => { - const size = count >= 100 ? '0.85rem' : count >= 50 ? '0.75rem' : count >= 10 ? '0.68rem' : '0.6rem'; + const size = count >= 100 ? fontSize.md : count >= 50 ? fontSize.xs : count >= 10 ? fontSize.xxs : fontSize.xxs; const weight = count >= 50 ? 600 : count >= 10 ? 500 : 400; const opacity = count >= 100 ? 1 : count >= 50 ? 0.85 : count >= 10 ? 0.7 : 0.5; return ( @@ -362,13 +365,13 @@ export function StatsPage() { component={RouterLink} to={param ? `/?${param}=${tag}` : '/'} sx={{ - fontFamily: mono, fontSize: size, fontWeight: weight, textDecoration: 'none', + fontFamily: typography.fontFamily, fontSize: size, fontWeight: weight, textDecoration: 'none', px: 0.75, py: 0.25, borderRadius: 0.5, color: `rgba(55, 65, 81, ${opacity})`, - '&:hover': { color: '#306998' }, + '&:hover': { color: colors.primaryDark }, }} > - {tag}{count} + {tag}{count} ); })} diff --git a/app/src/theme/index.ts b/app/src/theme/index.ts index d4552f6c9c..4d0d6fa736 100644 --- a/app/src/theme/index.ts +++ b/app/src/theme/index.ts @@ -36,6 +36,23 @@ export const colors = { // Background background: '#fafafa', + + // Extended brand + primaryDark: '#306998', // Python dark blue — dataviz bars, hover accents + accentBg: '#fffef5', // Warm yellow-tinted bg for accent sections + + // Highlights + highlight: { + bg: '#dbeafe', // Light blue bg for highlighted chips + text: '#1e40af', // Dark blue text for highlighted chips + }, + tooltipLight: '#90caf9', // Light blue text on dark tooltip backgrounds + + // Code blocks (dark theme) + codeBlock: { + bg: '#1e293b', + text: '#e2e8f0', + }, } as const; // Semantic text colors — WCAG AA safe on #fafafa/#fff backgrounds @@ -46,6 +63,8 @@ export const semanticColors = { } as const; export const fontSize = { + micro: '0.5rem', // 8px — decorative axis/legend labels only + xxs: '0.625rem', // 10px — data-dense dashboards (stats, debug) xs: '0.75rem', sm: '0.8rem', md: '0.875rem', @@ -72,3 +91,58 @@ export const labelStyle = { fontSize: fontSize.md, color: semanticColors.labelText, } as const; + +// Page-level style constants +export const headingStyle = { + fontFamily: typography.fontFamily, + fontWeight: 600, + fontSize: '1.25rem', + color: colors.gray[800], + mb: 2, +} as const; + +export const subheadingStyle = { + fontFamily: typography.fontFamily, + fontWeight: 600, + fontSize: fontSize.lg, + color: colors.gray[700], + mt: 3, + mb: 1, +} as const; + +export const textStyle = { + fontFamily: typography.fontFamily, + fontSize: fontSize.base, + color: semanticColors.labelText, + lineHeight: 1.8, + mb: 2, +} as const; + +export const codeBlockStyle = { + fontFamily: typography.fontFamily, + fontSize: fontSize.md, + backgroundColor: colors.codeBlock.bg, + color: colors.codeBlock.text, + p: 2, + borderRadius: 1, + overflow: 'auto', + mb: 2, + whiteSpace: 'pre-wrap' as const, + wordBreak: 'break-word' as const, +} as const; + +export const tableStyle = { + '& .MuiTableCell-root': { + fontFamily: typography.fontFamily, + fontSize: fontSize.md, + color: semanticColors.labelText, + borderBottom: `1px solid ${colors.gray[100]}`, + py: 1.5, + px: 2, + }, + '& .MuiTableCell-head': { + fontWeight: 600, + color: colors.gray[700], + backgroundColor: colors.gray[50], + }, +} as const; diff --git a/docs/reference/style-guide.md b/docs/reference/style-guide.md index 0a4e5e8d4d..f2bc67470b 100644 --- a/docs/reference/style-guide.md +++ b/docs/reference/style-guide.md @@ -19,13 +19,15 @@ Design system for the pyplots.ai frontend. All values are defined in `app/src/th | `gray.800` | `#1f2937` | 13.5:1 | Primary text | | `gray.700` | `#374151` | 10.3:1 | Headings, hover states | | `gray.600` | `#4b5563` | 7.0:1 | Labels, categories | -| `gray.500` | `#6b7280` | 4.6:1 | Muted text, decorative | -| `gray.400` | `#9ca3af` | 2.9:1 | Borders, dividers only (fails AA for text) | -| `gray.300` | `#d1d5db` | — | Subtle borders | +| `gray.500` | `#6b7280` | 4.6:1 | Muted text, decorative icons | +| `gray.400` | `#9ca3af` | 2.9:1 | Borders, dividers, dashed outlines ONLY | +| `gray.300` | `#d1d5db` | — | Subtle borders, missing-data indicators | | `gray.200` | `#e5e7eb` | — | Card borders, dividers | | `gray.100` | `#f3f4f6` | — | Breadcrumb background, chips | | `gray.50` | `#f9fafb` | — | Subtle backgrounds | +**Gray Usage Rule:** `gray.400` (#9ca3af) and lighter grays **must never be used for text or interactive icon colors**. They fail WCAG AA contrast requirements (2.9:1 vs required 4.5:1). Use `semanticColors.mutedText` (#6b7280, 4.6:1) as the minimum contrast for any text. + ### Semantic Text Colors WCAG AA requires 4.5:1 contrast for normal text, 3:1 for large text (>=18px or >=14px bold). @@ -36,13 +38,37 @@ WCAG AA requires 4.5:1 contrast for normal text, 3:1 for large text (>=18px or > | `semanticColors.subtleText` | `#52525b` | 5.8:1 | Secondary text, descriptions, metadata | | `semanticColors.mutedText` | `#6b7280` | 4.6:1 | Decorative text, footer links, breadcrumb separators | +### Extended Brand + +| Token | Hex | Usage | +|-------|-----|-------| +| `colors.primaryDark` | `#306998` | Python dark blue — dataviz bars, chart hover accents | +| `colors.accentBg` | `#fffef5` | Warm yellow-tinted background for accent sections | + +### Highlight Colors + +| Token | Hex | Usage | +|-------|-----|-------| +| `colors.highlight.bg` | `#dbeafe` | Light blue background for highlighted tag chips | +| `colors.highlight.text` | `#1e40af` | Dark blue text for highlighted tag chips | +| `colors.tooltipLight` | `#90caf9` | Light blue text/links on dark tooltip backgrounds | + +### Code Block Colors + +| Token | Hex | Usage | +|-------|-----|-------| +| `colors.codeBlock.bg` | `#1e293b` | Dark code block background | +| `colors.codeBlock.text` | `#e2e8f0` | Code block text on dark background | + +Inline code uses `colors.gray[100]` background with `colors.primary` text color. + ### Semantic Status | Token | Hex | Usage | |-------|-----|-------| -| `colors.success` | `#22c55e` | Success states | -| `colors.error` | `#ef4444` | Error states | -| `colors.warning` | `#f59e0b` | Warnings | +| `colors.success` | `#22c55e` | Success states, score >= 90 | +| `colors.error` | `#ef4444` | Error states, score < 50 | +| `colors.warning` | `#f59e0b` | Warnings, score 50-89 | | `colors.info` | `#3b82f6` | Info states | ## Typography @@ -59,6 +85,8 @@ MonoLisa is a premium monospace font loaded from GCS with unicode-range subsets. | Token | Size | Px (at 16px base) | Usage | |-------|------|-------|-------| +| `fontSize.micro` | 0.5rem | 8px | Decorative axis labels, coverage legends (data-dense pages only) | +| `fontSize.xxs` | 0.625rem | 10px | Compact data values, secondary counts (data-dense pages only) | | `fontSize.xs` | 0.75rem | 12px | Tiny labels, tag chips, keyboard hints | | `fontSize.sm` | 0.8rem | 12.8px | Small text, shared tags, metadata footer | | `fontSize.md` | 0.875rem | 14px | Body default, card labels, footer | @@ -149,14 +177,6 @@ max-width: 2200px (centered) - Inactive: `border: transparent`, `color: semanticColors.mutedText` - Background: `#f3f4f6` -### Lightbox - -- Backdrop: `rgba(0,0,0,0.85)` -- Image: `max-height: 90vh`, `max-width: 95vw`, `object-fit: contain` -- Close button: top-right, white icon on semi-transparent background -- Bottom bar: frosted glass (`backdrop-filter: blur(10px)`) -- Navigation arrows: centered vertically, semi-transparent circles - ### Tooltips (global) All tooltips share a consistent style defined in the MUI theme (`main.tsx`): @@ -199,6 +219,53 @@ All tooltips share a consistent style defined in the MUI theme (`main.tsx`): | Shuffle wiggle | 0.8s | ease | Random button icon | | Loader | 2s | linear | Loading spinner | +## Page Headings + +Shared style constants are exported from `app/src/theme/index.ts`: + +| Constant | Font Size | Weight | Color | Usage | +|----------|-----------|--------|-------|-------| +| `headingStyle` | 1.25rem | 600 | `gray.800` | Page section headings (h2) | +| `subheadingStyle` | `fontSize.lg` (1rem) | 600 | `gray.700` | Subsection headings (h3) | +| `textStyle` | `fontSize.base` (0.9375rem) | — | `labelText` | Body text paragraphs | + +## Tables + +Import `tableStyle` from theme for consistent table styling: + +- Cell text: `semanticColors.labelText` (#4b5563) +- Cell font: `fontSize.md` (0.875rem) +- Header: `fontWeight: 600`, `color: gray.700`, `bgcolor: gray.50` +- Cell borders: `1px solid gray.100` + +## Code Blocks + +Import `codeBlockStyle` from theme for dark code blocks: + +- Background: `colors.codeBlock.bg` (#1e293b) +- Text: `colors.codeBlock.text` (#e2e8f0) +- Font: `fontSize.md`, MonoLisa +- Inline code: `bgcolor: gray.100`, `color: colors.primary`, `padding: 4px 8px`, `borderRadius: 4px` + +## Chart / DataViz Colors + +| Element | Color Token | Usage | +|---------|------------|-------| +| Bar fills | `colors.primaryDark` | LOC histograms, timeline bars | +| Score >= 90 | `colors.success` | Green indicators | +| Score 50-89 | `colors.warning` | Yellow indicators | +| Score < 50 | `colors.error` | Red indicators | +| Missing/null | `colors.gray[200]` or `colors.gray[300]` | Background placeholder | +| Coverage heatmap | `rgba(34, 197, 94, opacity)` | Green gradient from 0.15 to 0.85 | + +## Data-Dense Pages + +StatsPage and DebugPage are internal data-dense pages that may use `fontSize.micro` and `fontSize.xxs` for compact data displays. These sizes are **not permitted** on public-facing pages (Home, Catalog, Spec, Legal, MCP). + +## ErrorBoundary Exception + +ErrorBoundary intentionally uses MUI defaults and raw `fontFamily: 'monospace'` as a crash-safe fallback. It must not depend on custom theme imports or MonoLisa font loading, since a font loading failure could have caused the crash. + ## Accessibility - All text must pass WCAG AA contrast (4.5:1 for normal, 3:1 for large) From 58fe471a368159e53711b3d3b2b4c0d929cc5f87 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:10:15 +0200 Subject: [PATCH 4/6] chore: update .gitignore to include screenshots directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b20dd5d958..734d171fc8 100644 --- a/.gitignore +++ b/.gitignore @@ -241,3 +241,4 @@ agentic/runs/ docker-compose.override.yml secrets/ /.playwright-mcp/ +/screenshots/ From 1c1387e57ebc6719fbf99270af2e04ee6e0a5ccb Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:18:04 +0200 Subject: [PATCH 5/6] fix: update tests for PlotOfTheDay API fields and useCodeFetch endpoint - Add library_version/python_version to mock_impl in test_potd_with_db - Update useCodeFetch tests to match /specs/{id}/{lib}/code endpoint - Fix Footer test: stats link fires internal_link, not external_link Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/Footer.test.tsx | 2 +- app/src/hooks/useCodeFetch.test.ts | 48 +++++++++++++----------------- tests/unit/api/test_routers.py | 2 ++ 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/app/src/components/Footer.test.tsx b/app/src/components/Footer.test.tsx index 7e53f1d5a9..de8fb8ee49 100644 --- a/app/src/components/Footer.test.tsx +++ b/app/src/components/Footer.test.tsx @@ -35,7 +35,7 @@ describe('Footer', () => { render(