From 7629e007c1f96d363ec8ac3201ae9c05093c94de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 17:30:04 +0000 Subject: [PATCH 1/7] feat(analytics,seo): track new landing-page chrome + tighten SEO Tracking gaps after the editorial-design switch left the entire landing chrome (NavBar, MastheadRule, HeroSection, PlotOfTheDay, featured grid, library cards, palette section) ungtracked. Adds a single `nav_click` event keyed by `source` + `target` so we can answer "where do users go from `/` and via which UI element". Adds `theme_toggle` event and a `theme` ambient prop attached to every pageview/event so dark-vs-light usage is finally measurable. Adds `potd_dismiss` for plot-of-the-day. SEO: refresh index.html title/description/og to match the new editorial positioning, add `theme-color` per color-scheme, `og:locale`, a SearchAction in the JSON-LD, BreadcrumbList JSON-LD on spec pages, and canonicals on /stats and /palette. plausible.md is updated to reflect the new event surface and the ambient `theme` prop registration requirement. --- app/index.html | 28 +++++++---- app/src/components/HeroSection.tsx | 33 +++++++++++-- app/src/components/MastheadRule.tsx | 34 +++++++++++-- app/src/components/NavBar.tsx | 8 ++- app/src/components/PlotOfTheDay.tsx | 12 ++++- app/src/components/PlotOfTheDayTerminal.tsx | 5 ++ app/src/components/RootLayout.tsx | 8 +++ app/src/components/SectionHeader.tsx | 3 ++ app/src/hooks/useAnalytics.ts | 19 ++++++-- app/src/pages/LandingPage.tsx | 33 +++++++++++-- app/src/pages/PalettePage.tsx | 1 + app/src/pages/SpecPage.tsx | 21 ++++++++ app/src/pages/StatsPage.tsx | 1 + docs/reference/plausible.md | 54 +++++++++++++++++++-- 14 files changed, 223 insertions(+), 37 deletions(-) diff --git a/app/index.html b/app/index.html index c8508b44a3..d58d49a3d6 100644 --- a/app/index.html +++ b/app/index.html @@ -4,22 +4,25 @@ - - - anyplot.ai + + + + + any.plot() — any library. - - + + + - - + + @@ -40,7 +43,7 @@ "@type": "WebApplication", "name": "anyplot.ai", "url": "https://anyplot.ai", - "description": "library-agnostic, ai-powered python plotting.", + "description": "the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 9 python libraries.", "applicationCategory": "DeveloperApplication", "operatingSystem": "Any", "offers": { @@ -53,8 +56,13 @@ "name": "Markus Neusinger", "url": "https://www.linkedin.com/in/markus-neusinger/" }, - "keywords": ["python", "plotting", "matplotlib", "seaborn", "plotly", "bokeh", "altair", "plotnine", "pygal", "highcharts", "letsplot", "data visualization", "code examples"], - "isAccessibleForFree": true + "keywords": ["python", "plotting", "matplotlib", "seaborn", "plotly", "bokeh", "altair", "plotnine", "pygal", "highcharts", "letsplot", "data visualization", "code examples", "colorblind-safe", "okabe-ito"], + "isAccessibleForFree": true, + "potentialAction": { + "@type": "SearchAction", + "target": "https://anyplot.ai/plots?focus=search", + "query-input": "required name=search_term_string" + } } diff --git a/app/src/components/HeroSection.tsx b/app/src/components/HeroSection.tsx index 44e5292420..59a29863a4 100644 --- a/app/src/components/HeroSection.tsx +++ b/app/src/components/HeroSection.tsx @@ -5,6 +5,7 @@ import { colors, typography } from '../theme'; import { TypewriterText } from './TypewriterText'; import { PlotOfTheDayTerminal } from './PlotOfTheDayTerminal'; import type { PlotOfTheDayData } from '../hooks/usePlotOfTheDay'; +import { useAnalytics } from '../hooks'; interface HeroSectionProps { potd?: PlotOfTheDayData | null; @@ -17,6 +18,7 @@ interface HeroSectionProps { * page reads as one continuous grid. */ export function HeroSection({ potd = null }: HeroSectionProps) { + const { trackEvent } = useAnalytics(); return ( - + trackEvent('nav_click', { source: 'hero_cta_browse', target: '/plots' })} + /> @@ -194,11 +215,12 @@ export function HeroSection({ potd = null }: HeroSectionProps) { ); } -function PrimaryCta({ to, subject, verb, ariaLabel }: { to: string; subject: string; verb: string; ariaLabel: string }) { +function PrimaryCta({ to, subject, verb, ariaLabel, onClick }: { to: string; subject: string; verb: string; ariaLabel: string; onClick?: () => void }) { return ( void; }) { const linkProps = external ? { component: 'a' as const, href, target: '_blank', rel: 'noopener noreferrer' } @@ -250,6 +274,7 @@ function SecondaryLink({ return ( { + trackEvent('theme_toggle', { to: isDark ? 'light' : 'dark' }); + toggle(); + }; + // Pick one random comment style per browser session (stable across client-side nav). const [randomIdx] = useState(() => Math.floor(Math.random() * COMMENT_POOL.length)); @@ -156,14 +162,26 @@ export function MastheadRule() { textOverflow: 'ellipsis', }}> {/* Always-visible root marker */} - + trackEvent('nav_click', { source: 'masthead_logo', target: '/' })} + sx={linkSx} + > ~/anyplot.ai {isLanding ? ( <> {' · '} - + trackEvent('nav_click', { source: 'masthead_branch', target: 'github_main' })} + sx={linkSx} + > main {' · '} @@ -172,6 +190,7 @@ export function MastheadRule() { href={releaseTag ? `${REPO_URL}/releases/tag/${releaseTag}` : `${REPO_URL}/releases`} target="_blank" rel="noopener noreferrer" + onClick={() => trackEvent('nav_click', { source: 'masthead_release', target: version })} sx={linkSx} > {version} @@ -182,7 +201,12 @@ export function MastheadRule() { {' · '} {seg.to ? ( - + trackEvent('nav_click', { source: 'breadcrumb', target: seg.to })} + sx={linkSx} + > {seg.label} ) : ( @@ -210,7 +234,7 @@ export function MastheadRule() { display: 'flex', justifyContent: 'flex-end', }}> - + ); diff --git a/app/src/components/NavBar.tsx b/app/src/components/NavBar.tsx index 10be15375c..cdfb197760 100644 --- a/app/src/components/NavBar.tsx +++ b/app/src/components/NavBar.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom'; import Box from '@mui/material/Box'; import { colors, typography } from '../theme'; +import { useAnalytics } from '../hooks'; const DEBUG_CLICK_COUNT = 5; const DEBUG_CLICK_WINDOW_MS = 800; @@ -56,6 +57,7 @@ const activeLinkSx = { export function NavBar() { const navigate = useNavigate(); const location = useLocation(); + const { trackEvent } = useAnalytics(); const clickCountRef = useRef(0); const clickTimerRef = useRef | null>(null); @@ -66,6 +68,7 @@ export function NavBar() { }, []); const handleSearch = () => { + trackEvent('nav_click', { source: 'nav_search', target: '/plots?focus=search' }); navigate('/plots?focus=search'); }; @@ -82,9 +85,11 @@ export function NavBar() { clickCountRef.current = 0; if (clickTimerRef.current) clearTimeout(clickTimerRef.current); navigate('/debug'); + return; } + trackEvent('nav_click', { source: 'nav_logo', target: '/' }); }, - [navigate] + [navigate, trackEvent] ); return ( @@ -140,6 +145,7 @@ export function NavBar() { trackEvent('nav_click', { source: `nav_${link.label}`, target: link.to })} sx={location.pathname === link.to ? activeLinkSx : linkSx} > {/* Full label on ≥md, short label on smaller screens where it saves room */} diff --git a/app/src/components/PlotOfTheDay.tsx b/app/src/components/PlotOfTheDay.tsx index 6aca6901ea..e8c8160086 100644 --- a/app/src/components/PlotOfTheDay.tsx +++ b/app/src/components/PlotOfTheDay.tsx @@ -12,6 +12,7 @@ import { colors, typography, fontSize, semanticColors } from '../theme'; import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; import { selectPreviewUrl } from '../utils/themedPreview'; import { useTheme } from '../hooks/useLayoutContext'; +import { useAnalytics } from '../hooks'; import { specPath } from '../utils/paths'; interface PlotOfTheDayData { @@ -38,6 +39,7 @@ export function PlotOfTheDay() { const [loading, setLoading] = useState(true); const [dismissed, setDismissed] = useState(() => window.sessionStorage.getItem('potd_dismissed') === 'true'); const { isDark } = useTheme(); + const { trackEvent } = useAnalytics(); const previewUrl = selectPreviewUrl(data, isDark); useEffect(() => { @@ -51,9 +53,10 @@ export function PlotOfTheDay() { const handleDismiss = useCallback((e: React.MouseEvent) => { e.stopPropagation(); + trackEvent('potd_dismiss', { spec: data?.spec_id, library: data?.library_id }); setDismissed(true); window.sessionStorage.setItem('potd_dismissed', 'true'); - }, []); + }, [trackEvent, data]); // Already dismissed — no space needed (user saw page before) if (dismissed) return null; @@ -102,7 +105,10 @@ export function PlotOfTheDay() { href={`${GITHUB_URL}/blob/main/plots/${data.spec_id}/implementations/${data.library_id}.py`} target="_blank" rel="noopener noreferrer" - onClick={(e: React.MouseEvent) => e.stopPropagation()} + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + trackEvent('nav_click', { source: 'potd_source_link', target: 'github', spec: data.spec_id, library: data.library_id }); + }} sx={{ fontFamily: mono, fontSize: fontSize.xxs, color: semanticColors.mutedText, flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', @@ -134,6 +140,7 @@ export function PlotOfTheDay() { trackEvent('nav_click', { source: 'potd_image', target: 'spec_detail', spec: data.spec_id, library: data.library_id })} sx={{ display: 'block', textDecoration: 'none', @@ -190,6 +197,7 @@ export function PlotOfTheDay() { trackEvent('nav_click', { source: 'potd_title', target: 'spec_detail', spec: data.spec_id, library: data.library_id })} sx={{ textDecoration: 'none', color: 'var(--ink)', diff --git a/app/src/components/PlotOfTheDayTerminal.tsx b/app/src/components/PlotOfTheDayTerminal.tsx index a40c2f32e7..b0ef836275 100644 --- a/app/src/components/PlotOfTheDayTerminal.tsx +++ b/app/src/components/PlotOfTheDayTerminal.tsx @@ -6,6 +6,7 @@ import { colors, typography } from '../theme'; import { buildSrcSet, getFallbackSrc } from '../utils/responsiveImage'; import { selectPreviewUrl } from '../utils/themedPreview'; import { useTheme } from '../hooks/useLayoutContext'; +import { useAnalytics } from '../hooks'; import { specPath } from '../utils/paths'; import type { PlotOfTheDayData } from '../hooks/usePlotOfTheDay'; @@ -37,6 +38,7 @@ export function PlotOfTheDayTerminal({ maxPlotHeight = '55vh', }: PlotOfTheDayTerminalProps) { const { isDark } = useTheme(); + const { trackEvent } = useAnalytics(); const previewUrl = selectPreviewUrl(potd, isDark); if (!potd || !previewUrl) return null; @@ -89,6 +91,7 @@ export function PlotOfTheDayTerminal({ href={githubFileUrl} target="_blank" rel="noopener noreferrer" + onClick={() => trackEvent('nav_click', { source: 'potd_terminal_filename', target: 'github', spec: potd.spec_id, library: potd.library_id })} sx={{ flex: 1, minWidth: 0, @@ -112,6 +115,7 @@ export function PlotOfTheDayTerminal({ target="_blank" rel="noopener noreferrer" aria-label="Open source on GitHub" + onClick={() => trackEvent('nav_click', { source: 'potd_terminal_github', target: 'github', spec: potd.spec_id, library: potd.library_id })} sx={{ color: 'inherit', textDecoration: 'none', @@ -131,6 +135,7 @@ export function PlotOfTheDayTerminal({ component={RouterLink} to={implPath} aria-label={`Open ${potd.spec_title} implementation for ${potd.library_name}`} + onClick={() => trackEvent('nav_click', { source: 'potd_terminal_image', target: 'spec_detail', spec: potd.spec_id, library: potd.library_id })} sx={{ display: 'block', mx: 'auto', diff --git a/app/src/components/RootLayout.tsx b/app/src/components/RootLayout.tsx index e0b398a38f..a148bf31ae 100644 --- a/app/src/components/RootLayout.tsx +++ b/app/src/components/RootLayout.tsx @@ -1,8 +1,11 @@ +import { useEffect } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; import { useAnalytics } from '../hooks'; +import { setAnalyticsAmbientProps } from '../hooks/useAnalytics'; +import { useTheme } from '../hooks/useLayoutContext'; import { MastheadRule } from './MastheadRule'; import { NavBar } from './NavBar'; import { Footer } from './Footer'; @@ -23,8 +26,13 @@ const containerSx = { export function RootLayout() { const { trackEvent } = useAnalytics(); const { pathname } = useLocation(); + const { isDark } = useTheme(); const mastheadSticks = pathname !== '/plots'; + useEffect(() => { + setAnalyticsAmbientProps({ theme: isDark ? 'dark' : 'light' }); + }, [isDark]); + return ( trackEvent('nav_click', { source: 'section_header', target: linkTo })} sx={{ fontFamily: typography.mono, fontSize: '12px', diff --git a/app/src/hooks/useAnalytics.ts b/app/src/hooks/useAnalytics.ts index 71cd19c10c..e529d9b3bf 100644 --- a/app/src/hooks/useAnalytics.ts +++ b/app/src/hooks/useAnalytics.ts @@ -5,6 +5,15 @@ interface EventProps { [key: string]: string | undefined; } +// Module-level ambient props attached to every pageview and custom event. +// Set by the layout once theme/locale-style values are known so we don't have +// to thread them through every component that fires an event. +let ambientProps: Record = {}; + +export function setAnalyticsAmbientProps(props: Record): void { + ambientProps = { ...ambientProps, ...props }; +} + function debounce void>( fn: T, delay: number, @@ -90,7 +99,8 @@ export function useAnalytics() { if (url === lastPageviewRef.current) return; lastPageviewRef.current = url; - window.plausible?.("pageview", { url }); + const props = Object.keys(ambientProps).length > 0 ? { ...ambientProps } : undefined; + window.plausible?.("pageview", props ? { url, props } : { url }); }, [isProduction], ); @@ -107,12 +117,11 @@ export function useAnalytics() { ? Object.fromEntries( Object.entries(props).filter(([, v]) => v !== undefined), ) - : undefined; + : {}; + const merged = { ...ambientProps, ...cleanProps } as Record; window.plausible?.( name, - cleanProps - ? { props: cleanProps as Record } - : undefined, + Object.keys(merged).length > 0 ? { props: merged } : undefined, ); }, [isProduction], diff --git a/app/src/pages/LandingPage.tsx b/app/src/pages/LandingPage.tsx index d15ab820ca..fa75c40148 100644 --- a/app/src/pages/LandingPage.tsx +++ b/app/src/pages/LandingPage.tsx @@ -6,7 +6,7 @@ import { HeroSection } from '../components/HeroSection'; import { NumbersStrip } from '../components/NumbersStrip'; import { LibrariesSection } from '../components/LibrariesSection'; import { SectionHeader } from '../components/SectionHeader'; -import { useAppData } from '../hooks'; +import { useAppData, useAnalytics } from '../hooks'; import { usePlotOfTheDay } from '../hooks/usePlotOfTheDay'; import { useFeaturedSpecs, type FeaturedImpl } from '../hooks/useFeaturedSpecs'; import { useTheme } from '../hooks/useLayoutContext'; @@ -21,6 +21,12 @@ export function LandingPage() { const potd = usePlotOfTheDay(); const featured = useFeaturedSpecs(5); const navigate = useNavigate(); + const { trackEvent } = useAnalytics(); + + const handleLibraryClick = (lib: string) => { + trackEvent('nav_click', { source: 'library_card', target: '/plots', value: lib }); + navigate(`/plots?lib=${encodeURIComponent(lib)}`); + }; return ( <> @@ -52,7 +58,7 @@ export function LandingPage() { navigate(`/plots?lib=${encodeURIComponent(lib)}`)} + onLibraryClick={handleLibraryClick} widthTier="catalog" /> @@ -66,6 +72,7 @@ export function LandingPage() { * the left, labelled palette strip on the right. */ function PaletteSection() { + const { trackEvent } = useAnalytics(); return ( palette} linkText="palette.explore()" linkTo="/palette" /> @@ -95,6 +102,7 @@ function PaletteSection() { href="https://jfly.uni-koeln.de/color/" target="_blank" rel="noopener noreferrer" + onClick={() => trackEvent('nav_click', { source: 'palette_okabe_ito', target: 'jfly_uni_koeln' })} sx={{ color: 'var(--ink)', textDecoration: 'none', @@ -202,6 +210,7 @@ function LabelledPaletteStrip() { * specs become. */ function SpecsSection({ specCount, featured }: { specCount?: number; featured: FeaturedImpl[] | null }) { + const { trackEvent } = useAnalytics(); return ( specifications} linkText="specs.all()" linkTo="/specs" /> @@ -232,6 +241,7 @@ function SpecsSection({ specCount, featured }: { specCount?: number; featured: F subject="spec" verb="suggest" external + source="suggest_spec_link" /> @@ -241,6 +251,7 @@ function SpecsSection({ specCount, featured }: { specCount?: number; featured: F trackEvent('nav_click', { source: 'specs_more_link', target: '/specs' })} sx={{ display: 'inline-block', mt: 2.5, @@ -291,6 +302,7 @@ interface MethodLinkProps { subject: string; verb: string; external?: boolean; + source?: string; } /** @@ -298,7 +310,11 @@ interface MethodLinkProps { * rendered muted (opacity 0.7), `.verb()` at the link's current colour. * Whole element turns primary-green on hover; subject brightens to full. */ -function MethodLink({ to, href, subject, verb, external }: MethodLinkProps) { +function MethodLink({ to, href, subject, verb, external, source }: MethodLinkProps) { + const { trackEvent } = useAnalytics(); + const handleClick = source + ? () => trackEvent('nav_click', { source, target: href ?? to ?? '' }) + : undefined; const sx = { fontFamily: typography.mono, fontSize: '13px', @@ -327,6 +343,7 @@ function MethodLink({ to, href, subject, verb, external }: MethodLinkProps) { href={href} target={external ? '_blank' : undefined} rel={external ? 'noopener noreferrer' : undefined} + onClick={handleClick} sx={sx} > {content} @@ -334,7 +351,7 @@ function MethodLink({ to, href, subject, verb, external }: MethodLinkProps) { ); } return ( - + {content} ); @@ -348,6 +365,7 @@ function MethodLink({ to, href, subject, verb, external }: MethodLinkProps) { */ function FeaturedThumb({ item }: { item: FeaturedImpl | null }) { const { isDark } = useTheme(); + const { trackEvent } = useAnalytics(); const previewUrl = selectPreviewUrl(item, isDark); const cardSx = { display: 'flex', @@ -430,7 +448,12 @@ function FeaturedThumb({ item }: { item: FeaturedImpl | null }) { } return ( - + trackEvent('nav_click', { source: 'featured_thumb', target: 'spec_hub', spec: item.spec_id, library: item.library_id })} + sx={cardSx} + > diff --git a/app/src/pages/PalettePage.tsx b/app/src/pages/PalettePage.tsx index e8c30677ed..e14b653564 100644 --- a/app/src/pages/PalettePage.tsx +++ b/app/src/pages/PalettePage.tsx @@ -32,6 +32,7 @@ export function PalettePage() { palette | anyplot.ai + diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index d5ee19bd83..51bf67c179 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -324,6 +324,26 @@ export function SpecPage() { ? specData.implementations.filter((i) => i.language === languageFilter) : specData.implementations; + const breadcrumbItems: { name: string; item: string }[] = [ + { name: 'anyplot', item: 'https://anyplot.ai/' }, + { name: 'specs', item: 'https://anyplot.ai/specs' }, + { name: specData.title, item: `https://anyplot.ai/${specId}` }, + ]; + if (mode === 'detail' && urlLanguage && selectedLibrary) { + breadcrumbItems.push({ name: urlLanguage, item: `https://anyplot.ai/${specId}/${urlLanguage}` }); + breadcrumbItems.push({ name: selectedLibrary, item: canonical }); + } + const breadcrumbJsonLd = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: breadcrumbItems.map((b, i) => ({ + '@type': 'ListItem', + position: i + 1, + name: b.name, + item: b.item, + })), + }; + return ( <> @@ -334,6 +354,7 @@ export function SpecPage() { {currentImpl?.preview_url && } + diff --git a/app/src/pages/StatsPage.tsx b/app/src/pages/StatsPage.tsx index 9bd9c211d5..c33e39498d 100644 --- a/app/src/pages/StatsPage.tsx +++ b/app/src/pages/StatsPage.tsx @@ -126,6 +126,7 @@ export function StatsPage() { stats | anyplot.ai + diff --git a/docs/reference/plausible.md b/docs/reference/plausible.md index ead347766a..9a5d9835c2 100644 --- a/docs/reference/plausible.md +++ b/docs/reference/plausible.md @@ -127,9 +127,36 @@ https://anyplot.ai/{spec_id}/{language}/{library}/{category}/{value}/... | `tab_toggle` | `action`, `tab`, `library` | SpecTabs.tsx | User opens or closes a tab | | `plot_rotate` | `spec` | SpecsListPage.tsx | User clicks image on specs page to rotate library | | `open_interactive` | `spec`, `library` | SpecOverview.tsx, SpecDetailView.tsx | User opens interactive HTML view | -| `suggest_spec` | - | CatalogPage.tsx | User clicks "suggest spec" link | +| `suggest_spec` | - | LandingPage.tsx (legacy) | User clicks "suggest spec" link — superseded by `nav_click` with `source: suggest_spec_link` | | `report_issue` | `spec`, `library`? | SpecPage.tsx | User clicks "report issue" link | | `tag_click` | `param`, `value`, `source` | SpecTabs.tsx | User clicks a tag chip to filter | +| `theme_toggle` | `to` | MastheadRule.tsx | User toggles dark/light mode (`to` ∈ `dark`, `light`) | +| `potd_dismiss` | `spec`, `library` | PlotOfTheDay.tsx | User dismisses the plot-of-the-day banner | + +### Landing Page Navigation (`nav_click`) + +A single event captures every clickable surface on the chrome and the new +editorial landing page so we can answer "where do users go from `/` and via +which UI element". One event, one event-property pair: `source` (which UI +element was clicked) + `target` (where it leads). Some sources additionally +carry `spec`, `library`, or `value` for richer breakdowns. + +| `source` value | Where | Target | +|----------------|-------|--------| +| `nav_specs` / `nav_plots` / `nav_libraries` / `nav_stats` / `nav_palette` / `nav_mcp` | NavBar.tsx | top-level menu bar | +| `nav_logo` | NavBar.tsx | logo → `/` | +| `nav_search` | NavBar.tsx | `plots.search()` button → `/plots?focus=search` | +| `masthead_logo` / `masthead_branch` / `masthead_release` | MastheadRule.tsx | masthead `~/anyplot.ai · main · v1.x.x` | +| `breadcrumb` | MastheadRule.tsx | breadcrumb segments on non-landing routes | +| `hero_cta_browse` / `hero_mcp` / `hero_github` | HeroSection.tsx | hero call-to-action + secondary links | +| `potd_image` / `potd_title` / `potd_source_link` | PlotOfTheDay.tsx | dismissible plot-of-the-day banner | +| `potd_terminal_image` / `potd_terminal_filename` / `potd_terminal_github` | PlotOfTheDayTerminal.tsx | hero terminal-framed POTD | +| `featured_thumb` | LandingPage.tsx | featured plot grid | +| `library_card` | LandingPage.tsx | library cards (carries `value=`) | +| `section_header` | SectionHeader.tsx | `specs.all()` / `libraries.all()` / `palette.explore()` headers | +| `specs_more_link` | LandingPage.tsx | `+ N more in the catalogue →` | +| `suggest_spec_link` | LandingPage.tsx | `spec.suggest()` GitHub-issue link | +| `palette_okabe_ito` | LandingPage.tsx | external Okabe & Ito reference | **Random methods**: - `click`: Shuffle icon clicked @@ -278,7 +305,10 @@ To see event properties in Plausible dashboard, you **MUST** register them as cu | `action` | Toggle action (open, close) | `tab_toggle` | | `size` | Grid size (normal, compact) | `grid_resize` | | `param` | URL parameter name for tag | `tag_click` | -| `source` | Source page context | `tag_click` | +| `source` | Source UI element / page context | `tag_click`, `nav_click` | +| `target` | Click destination (route or external label) | `nav_click` | +| `to` | New mode after toggle (`dark` / `light`) | `theme_toggle` | +| `theme` | Ambient theme prop attached to **every** pageview & event (`dark` / `light`) | all events (set in RootLayout via `setAnalyticsAmbientProps`) | | `rating` | CWV rating (good, needs-improvement, poor) | `LCP`, `CLS`, `INP` | | `filter_lib` | Library filter value (for og:image) | `og_image_view` | | `filter_spec` | Specification filter value (for og:image) | `og_image_view` | @@ -315,6 +345,9 @@ To see event properties in Plausible dashboard, you **MUST** register them as cu | `report_issue` | Custom Event | Track issue report clicks | | `tag_click` | Custom Event | Track tag filter clicks | | `plot_rotate` | Custom Event | Track plot image rotation on specs page | +| `nav_click` | Custom Event | Track which UI element on landing/chrome leads users off the root | +| `theme_toggle` | Custom Event | Track dark/light theme switches | +| `potd_dismiss` | Custom Event | Track plot-of-the-day banner dismissals | | `og_image_view` | Custom Event | Track og:image requests from social media bots | | `LCP` | Custom Event | Largest Contentful Paint (Core Web Vital) | | `CLS` | Custom Event | Cumulative Layout Shift (Core Web Vital) | @@ -402,12 +435,20 @@ User lands on anyplot.ai | `report_issue` | `spec`, `library`? | SpecPage.tsx | | `external_link` | `destination`, `spec`?, `library`? | Footer.tsx, LegalPage.tsx | | `internal_link` | `destination`, `spec`, `library` | Footer.tsx | +| `nav_click` | `source`, `target`, `spec`?, `library`?, `value`? | NavBar, MastheadRule, HeroSection, SectionHeader, PlotOfTheDay, PlotOfTheDayTerminal, LandingPage | +| `theme_toggle` | `to` | MastheadRule.tsx | +| `potd_dismiss` | `spec`, `library` | PlotOfTheDay.tsx | | `LCP` | `value`, `rating` | reportWebVitals.ts | | `CLS` | `value`, `rating` | reportWebVitals.ts | | `INP` | `value`, `rating` | reportWebVitals.ts | | `og_image_view` | `page`, `platform`, `spec`?, `language`?, `library`?, `filter_*`? | api/analytics.py (server-side) | -**Total: 19 client-side + 1 server-side = 20 events** +**Total: 22 client-side + 1 server-side = 23 events** + +> Every pageview and event additionally carries a `theme` ambient prop (`dark` / +> `light`). Set in `RootLayout` via `setAnalyticsAmbientProps` whenever the user +> toggles the theme — register `theme` as a custom property to see the +> dark-vs-light split per URL. --- @@ -554,6 +595,9 @@ window.plausible = function(...args) { console.log('Plausible:', args); }; - [x] Plot rotation (`plot_rotate`) - [x] Core Web Vitals tracking (`LCP`, `CLS`, `INP`) - [x] Server-side og:image tracking (`og_image_view`) with platform detection +- [x] Landing-page navigation tracking (`nav_click`) +- [x] Theme tracking (`theme_toggle` event + `theme` ambient pageview prop) +- [x] Plot-of-the-day dismissal (`potd_dismiss`) ### Plausible Dashboard Checklist @@ -564,5 +608,5 @@ window.plausible = function(...args) { console.log('Plausible:', args); }; --- -**Last Updated**: 2026-03-10 -**Status**: Production-ready with full journey tracking, Core Web Vitals, and server-side og:image analytics +**Last Updated**: 2026-04-25 +**Status**: Production-ready with full journey tracking, Core Web Vitals, server-side og:image analytics, landing-page nav tracking, and theme analytics From 5e69f73926f47e829098e987b00216da3c1b0f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 20:43:40 +0000 Subject: [PATCH 2/7] fix(analytics,seo,tests): address PR review + lift patch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review comments: - LandingPage now fires `trackPageview('/')` on mount; without it the new root page emitted no pageview (Plausible runs with autoCapturePageviews: false). - Set the `theme` ambient prop synchronously during RootLayout render instead of in a useEffect, so the first pageview from a child page's effect (which runs before the parent's) carries the theme. Empty-string values now clear an ambient key, making test reset trivial. - plausible.md: `suggest_spec` is still produced by SpecsListPage; the doc previously implied only the legacy LandingPage path. - Drop the SearchAction JSON-LD: the suggested target included a `{search_term_string}` placeholder, but PlotsPage only consumes `?focus=search` (no search-by-URL). An invalid SearchAction is ignored by Google — no SearchAction is cleaner. Tests for the new tracking surface to push codecov/patch/frontend above the 80% threshold: - useAnalytics: ambient props on events + pageviews, override semantics, empty-string clear - NavBar, MastheadRule, HeroSection, SectionHeader, PlotOfTheDayTerminal, PlotOfTheDay, LandingPage, RootLayout: assert each new nav_click / theme_toggle / potd_dismiss wiring --- app/index.html | 7 +- app/src/components/HeroSection.test.tsx | 42 +++++++ app/src/components/MastheadRule.test.tsx | 47 +++++++ app/src/components/NavBar.test.tsx | 63 ++++++++++ app/src/components/PlotOfTheDay.test.tsx | 35 ++++++ .../components/PlotOfTheDayTerminal.test.tsx | 61 +++++++++ app/src/components/RootLayout.test.tsx | 53 ++++++++ app/src/components/RootLayout.tsx | 8 +- app/src/components/SectionHeader.test.tsx | 34 +++++ app/src/hooks/useAnalytics.test.ts | 99 ++++++++++++++- app/src/hooks/useAnalytics.ts | 9 +- app/src/pages/LandingPage.test.tsx | 117 ++++++++++++++++++ app/src/pages/LandingPage.tsx | 7 +- docs/reference/plausible.md | 2 +- 14 files changed, 569 insertions(+), 15 deletions(-) create mode 100644 app/src/components/HeroSection.test.tsx create mode 100644 app/src/components/MastheadRule.test.tsx create mode 100644 app/src/components/NavBar.test.tsx create mode 100644 app/src/components/PlotOfTheDayTerminal.test.tsx create mode 100644 app/src/components/RootLayout.test.tsx create mode 100644 app/src/components/SectionHeader.test.tsx create mode 100644 app/src/pages/LandingPage.test.tsx diff --git a/app/index.html b/app/index.html index d58d49a3d6..b8d2ed2e4b 100644 --- a/app/index.html +++ b/app/index.html @@ -57,12 +57,7 @@ "url": "https://www.linkedin.com/in/markus-neusinger/" }, "keywords": ["python", "plotting", "matplotlib", "seaborn", "plotly", "bokeh", "altair", "plotnine", "pygal", "highcharts", "letsplot", "data visualization", "code examples", "colorblind-safe", "okabe-ito"], - "isAccessibleForFree": true, - "potentialAction": { - "@type": "SearchAction", - "target": "https://anyplot.ai/plots?focus=search", - "query-input": "required name=search_term_string" - } + "isAccessibleForFree": true } diff --git a/app/src/components/HeroSection.test.tsx b/app/src/components/HeroSection.test.tsx new file mode 100644 index 0000000000..570760bfd3 --- /dev/null +++ b/app/src/components/HeroSection.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '../test-utils'; + +const trackEvent = vi.fn(); + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks'); + return { + ...actual, + useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }), + }; +}); + +vi.mock('./PlotOfTheDayTerminal', () => ({ + PlotOfTheDayTerminal: () =>
, +})); + +vi.mock('./TypewriterText', () => ({ + TypewriterText: () =>
, +})); + +import { HeroSection } from './HeroSection'; + +describe('HeroSection', () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it('tracks the primary browse CTA, mcp link and github link', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText('Browse plots')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'hero_cta_browse', target: '/plots' }); + + await user.click(screen.getByLabelText('Connect via MCP')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'hero_mcp', target: '/mcp' }); + + await user.click(screen.getByLabelText('Clone on GitHub')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'hero_github', target: 'github' }); + }); +}); diff --git a/app/src/components/MastheadRule.test.tsx b/app/src/components/MastheadRule.test.tsx new file mode 100644 index 0000000000..2a6368f9c1 --- /dev/null +++ b/app/src/components/MastheadRule.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '../test-utils'; + +const trackEvent = vi.fn(); +const toggle = vi.fn(); + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks'); + return { + ...actual, + useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }), + useTheme: () => ({ isDark: false, toggle }), + useLatestRelease: () => 'v1.2.3', + }; +}); + +import { MastheadRule } from './MastheadRule'; + +describe('MastheadRule', () => { + beforeEach(() => { + trackEvent.mockClear(); + toggle.mockClear(); + }); + + it('fires theme_toggle event before invoking the underlying toggle', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText('Switch to dark theme')); + expect(trackEvent).toHaveBeenCalledWith('theme_toggle', { to: 'dark' }); + expect(toggle).toHaveBeenCalled(); + }); + + it('tracks nav_click on the masthead logo, branch, and release links', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('~/anyplot.ai')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'masthead_logo', target: '/' }); + + await user.click(screen.getByText('main')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'masthead_branch', target: 'github_main' }); + + await user.click(screen.getByText('v1.2.3')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'masthead_release', target: 'v1.2.3' }); + }); +}); diff --git a/app/src/components/NavBar.test.tsx b/app/src/components/NavBar.test.tsx new file mode 100644 index 0000000000..74b7fb8b52 --- /dev/null +++ b/app/src/components/NavBar.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '../test-utils'; + +const trackEvent = vi.fn(); +const navigate = vi.fn(); + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks'); + return { + ...actual, + useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }), + }; +}); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => navigate, + }; +}); + +import { NavBar } from './NavBar'; + +describe('NavBar', () => { + beforeEach(() => { + trackEvent.mockClear(); + navigate.mockClear(); + }); + + it('tracks nav_click on each menu link', async () => { + const user = userEvent.setup(); + const { container } = render(); + + // The labels render twice (full + short variant, CSS-hidden); we click the + // anchor itself via href to avoid the duplicate-text ambiguity. + await user.click(container.querySelector('a[href="/plots"]')!); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'nav_plots', target: '/plots' }); + + await user.click(container.querySelector('a[href="/specs"]')!); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'nav_specs', target: '/specs' }); + }); + + it('tracks nav_click on the logo', async () => { + const user = userEvent.setup(); + const { container } = render(); + + await user.click(container.querySelector('a[href="/"]')!); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'nav_logo', target: '/' }); + }); + + it('tracks nav_click and navigates on the search button', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByLabelText('Search plots')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { + source: 'nav_search', + target: '/plots?focus=search', + }); + expect(navigate).toHaveBeenCalledWith('/plots?focus=search'); + }); +}); diff --git a/app/src/components/PlotOfTheDay.test.tsx b/app/src/components/PlotOfTheDay.test.tsx index 3d4959fd96..4a28deb881 100644 --- a/app/src/components/PlotOfTheDay.test.tsx +++ b/app/src/components/PlotOfTheDay.test.tsx @@ -1,5 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '../test-utils'; + +const trackEvent = vi.fn(); + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks'); + return { + ...actual, + useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }), + }; +}); + import { PlotOfTheDay } from './PlotOfTheDay'; // Mock sessionStorage @@ -38,6 +49,7 @@ describe('PlotOfTheDay', () => { vi.stubGlobal('sessionStorage', sessionStorageStub); sessionStorageStub.getItem.mockClear(); sessionStorageStub.setItem.mockClear(); + trackEvent.mockClear(); }); afterEach(() => { @@ -142,6 +154,29 @@ describe('PlotOfTheDay', () => { // After dismiss, component should return null expect(container.firstChild).toBeNull(); expect(sessionStorageStub.setItem).toHaveBeenCalledWith('potd_dismissed', 'true'); + expect(trackEvent).toHaveBeenCalledWith('potd_dismiss', expect.objectContaining({ spec: 'scatter-basic', library: 'matplotlib' })); + }); + + it('tracks nav_click when the image, title and source link are clicked', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockData), + }); + + const { userEvent } = await import('../test-utils'); + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByText('plot of the day')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Basic Scatter Plot')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'potd_title' })); + + await user.click(screen.getByText(/python plots\/scatter-basic\/matplotlib\.py/)); + expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'potd_source_link' })); }); it('hides library version when it is "unknown"', async () => { diff --git a/app/src/components/PlotOfTheDayTerminal.test.tsx b/app/src/components/PlotOfTheDayTerminal.test.tsx new file mode 100644 index 0000000000..32017ec7bd --- /dev/null +++ b/app/src/components/PlotOfTheDayTerminal.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '../test-utils'; + +const trackEvent = vi.fn(); + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks'); + return { + ...actual, + useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }), + }; +}); + +vi.mock('../hooks/useLayoutContext', async () => { + const actual = await vi.importActual('../hooks/useLayoutContext'); + return { ...actual, useTheme: () => ({ isDark: false, toggle: vi.fn() }) }; +}); + +import { PlotOfTheDayTerminal } from './PlotOfTheDayTerminal'; + +const potd = { + spec_id: 'scatter-basic', + spec_title: 'Basic Scatter', + description: null, + library_id: 'matplotlib', + library_name: 'Matplotlib', + language: 'python', + quality_score: 9, + preview_url: 'https://cdn.example.com/plot.png', + preview_url_light: null, + preview_url_dark: null, + image_description: null, + library_version: '3.8.0', + python_version: '3.12', + date: '2026-04-25', +}; + +describe('PlotOfTheDayTerminal', () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it('renders nothing without potd data', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('tracks nav_click on filename, github link and image', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText(/plots\/scatter-basic\/matplotlib\.py/)); + expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'potd_terminal_filename' })); + + await user.click(screen.getByLabelText('Open source on GitHub')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'potd_terminal_github' })); + + await user.click(screen.getByLabelText(/Open Basic Scatter implementation/)); + expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'potd_terminal_image' })); + }); +}); diff --git a/app/src/components/RootLayout.test.tsx b/app/src/components/RootLayout.test.tsx new file mode 100644 index 0000000000..af6aa0fab1 --- /dev/null +++ b/app/src/components/RootLayout.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '../test-utils'; +import { Routes, Route } from 'react-router-dom'; + +const { setAmbient } = vi.hoisted(() => ({ setAmbient: vi.fn() })); + +vi.mock('../hooks/useAnalytics', async () => { + const actual = await vi.importActual('../hooks/useAnalytics'); + return { + ...actual, + setAnalyticsAmbientProps: setAmbient, + }; +}); + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks'); + return { + ...actual, + useAnalytics: () => ({ trackEvent: vi.fn(), trackPageview: vi.fn() }), + useLatestRelease: () => null, + }; +}); + +vi.mock('../hooks/useLayoutContext', async () => { + const actual = await vi.importActual('../hooks/useLayoutContext'); + return { ...actual, useTheme: () => ({ isDark: true, toggle: vi.fn() }) }; +}); + +vi.mock('./MastheadRule', () => ({ + MastheadRule: () =>
, +})); +vi.mock('./NavBar', () => ({ NavBar: () =>
})); +vi.mock('./Footer', () => ({ Footer: () =>
})); + +import { RootLayout } from './RootLayout'; + +describe('RootLayout', () => { + beforeEach(() => { + setAmbient.mockClear(); + }); + + it('synchronously sets the theme ambient prop on render', () => { + render( + + }> + home
} /> + + + ); + + expect(setAmbient).toHaveBeenCalledWith({ theme: 'dark' }); + }); +}); diff --git a/app/src/components/RootLayout.tsx b/app/src/components/RootLayout.tsx index a148bf31ae..0a3a1d1626 100644 --- a/app/src/components/RootLayout.tsx +++ b/app/src/components/RootLayout.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { Outlet, useLocation } from 'react-router-dom'; import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; @@ -29,9 +28,10 @@ export function RootLayout() { const { isDark } = useTheme(); const mastheadSticks = pathname !== '/plots'; - useEffect(() => { - setAnalyticsAmbientProps({ theme: isDark ? 'dark' : 'light' }); - }, [isDark]); + // Set synchronously during render so the first pageview from a child page's + // useEffect (which runs before the parent's useEffect) carries the theme prop. + // setAnalyticsAmbientProps merges into module state, so re-renders are safe. + setAnalyticsAmbientProps({ theme: isDark ? 'dark' : 'light' }); return ( { + const actual = await vi.importActual('../hooks'); + return { + ...actual, + useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }), + }; +}); + +import { SectionHeader } from './SectionHeader'; + +describe('SectionHeader', () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it('renders the title and prompt', () => { + render(); + expect(screen.getByText('❯')).toBeInTheDocument(); + expect(screen.getByText('libraries')).toBeInTheDocument(); + }); + + it('tracks nav_click when the action link is clicked', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('specs.all()')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'section_header', target: '/specs' }); + }); +}); diff --git a/app/src/hooks/useAnalytics.test.ts b/app/src/hooks/useAnalytics.test.ts index c8494161a2..7003d40a69 100644 --- a/app/src/hooks/useAnalytics.test.ts +++ b/app/src/hooks/useAnalytics.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; -import { useAnalytics } from './useAnalytics'; +import { useAnalytics, setAnalyticsAmbientProps } from './useAnalytics'; describe('useAnalytics', () => { const originalLocation = window.location; @@ -8,6 +8,8 @@ describe('useAnalytics', () => { beforeEach(() => { vi.useFakeTimers(); window.plausible = vi.fn(); + // Reset module-level ambient props between tests so each starts clean. + setAnalyticsAmbientProps({ theme: '' }); }); afterEach(() => { @@ -168,4 +170,99 @@ describe('useAnalytics', () => { expect(window.plausible).toHaveBeenCalled(); }); }); + + describe('ambient props', () => { + function setHostname(hostname: string) { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, hostname, search: '' }, + writable: true, + configurable: true, + }); + } + + it('attaches ambient props to custom events', () => { + setHostname('anyplot.ai'); + setAnalyticsAmbientProps({ theme: 'dark' }); + const { result } = renderHook(() => useAnalytics()); + + act(() => { + result.current.trackEvent('nav_click', { source: 'nav_logo' }); + }); + + expect(window.plausible).toHaveBeenCalledWith('nav_click', { + props: { theme: 'dark', source: 'nav_logo' }, + }); + }); + + it('attaches ambient props to events fired without explicit props', () => { + setHostname('anyplot.ai'); + setAnalyticsAmbientProps({ theme: 'light' }); + const { result } = renderHook(() => useAnalytics()); + + act(() => { + result.current.trackEvent('suggest_spec'); + }); + + expect(window.plausible).toHaveBeenCalledWith('suggest_spec', { + props: { theme: 'light' }, + }); + }); + + it('attaches ambient props to pageviews', () => { + setHostname('anyplot.ai'); + setAnalyticsAmbientProps({ theme: 'dark' }); + const { result } = renderHook(() => useAnalytics()); + + act(() => { + result.current.trackPageview('/specs'); + vi.advanceTimersByTime(200); + }); + + expect(window.plausible).toHaveBeenCalledWith('pageview', { + url: 'https://anyplot.ai/specs', + props: { theme: 'dark' }, + }); + }); + + it('omits the props key when no ambient props are set', () => { + setHostname('anyplot.ai'); + const { result } = renderHook(() => useAnalytics()); + + act(() => { + result.current.trackPageview('/about'); + vi.advanceTimersByTime(200); + }); + + expect(window.plausible).toHaveBeenCalledWith('pageview', { + url: 'https://anyplot.ai/about', + }); + }); + + it('event-level props override ambient values for the same key', () => { + setHostname('anyplot.ai'); + setAnalyticsAmbientProps({ theme: 'dark' }); + const { result } = renderHook(() => useAnalytics()); + + act(() => { + result.current.trackEvent('theme_toggle', { theme: 'light' }); + }); + + expect(window.plausible).toHaveBeenCalledWith('theme_toggle', { + props: { theme: 'light' }, + }); + }); + + it('clears a key when set to empty string', () => { + setHostname('anyplot.ai'); + setAnalyticsAmbientProps({ theme: 'dark' }); + setAnalyticsAmbientProps({ theme: '' }); + const { result } = renderHook(() => useAnalytics()); + + act(() => { + result.current.trackEvent('search_no_results'); + }); + + expect(window.plausible).toHaveBeenCalledWith('search_no_results', undefined); + }); + }); }); diff --git a/app/src/hooks/useAnalytics.ts b/app/src/hooks/useAnalytics.ts index e529d9b3bf..9217a5d19d 100644 --- a/app/src/hooks/useAnalytics.ts +++ b/app/src/hooks/useAnalytics.ts @@ -7,11 +7,16 @@ interface EventProps { // Module-level ambient props attached to every pageview and custom event. // Set by the layout once theme/locale-style values are known so we don't have -// to thread them through every component that fires an event. +// to thread them through every component that fires an event. Empty-string +// values clear the key (so callers can effectively reset a prop). let ambientProps: Record = {}; export function setAnalyticsAmbientProps(props: Record): void { - ambientProps = { ...ambientProps, ...props }; + const merged: Record = { ...ambientProps, ...props }; + for (const key of Object.keys(merged)) { + if (!merged[key]) delete merged[key]; + } + ambientProps = merged; } function debounce void>( diff --git a/app/src/pages/LandingPage.test.tsx b/app/src/pages/LandingPage.test.tsx new file mode 100644 index 0000000000..eff7c2d443 --- /dev/null +++ b/app/src/pages/LandingPage.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, userEvent } from '../test-utils'; + +const trackEvent = vi.fn(); +const trackPageview = vi.fn(); +const navigate = vi.fn(); + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks'); + return { + ...actual, + useAnalytics: () => ({ trackEvent, trackPageview }), + useAppData: () => ({ + specsData: [], + librariesData: [{ id: 'matplotlib', name: 'matplotlib' }], + stats: { specs: 100, plots: 900, libraries: 9, lines_of_code: 50000 }, + }), + }; +}); + +vi.mock('../hooks/useFeaturedSpecs', () => ({ + useFeaturedSpecs: () => [ + { + spec_id: 'scatter-basic', + spec_title: 'Basic Scatter', + spec_description: 'A scatter plot', + library_id: 'matplotlib', + preview_url: 'https://cdn.example.com/scatter.png', + preview_url_light: null, + preview_url_dark: null, + }, + ], +})); + +vi.mock('../hooks/usePlotOfTheDay', () => ({ + usePlotOfTheDay: () => null, +})); + +vi.mock('../hooks/useLayoutContext', async () => { + const actual = await vi.importActual('../hooks/useLayoutContext'); + return { ...actual, useTheme: () => ({ isDark: false, toggle: vi.fn() }) }; +}); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => navigate }; +}); + +vi.mock('../components/HeroSection', () => ({ + HeroSection: () =>
, +})); + +vi.mock('../components/NumbersStrip', () => ({ + NumbersStrip: () =>
, +})); + +vi.mock('react-helmet-async', () => ({ + Helmet: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +import { LandingPage } from './LandingPage'; + +describe('LandingPage', () => { + beforeEach(() => { + trackEvent.mockClear(); + trackPageview.mockClear(); + navigate.mockClear(); + }); + + it('fires a pageview for / on mount', () => { + render(); + expect(trackPageview).toHaveBeenCalledWith('/'); + }); + + it('tracks library card clicks and navigates', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('matplotlib')); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { + source: 'library_card', + target: '/plots', + value: 'matplotlib', + }); + expect(navigate).toHaveBeenCalledWith('/plots?lib=matplotlib'); + }); + + it('tracks featured thumb clicks', async () => { + const user = userEvent.setup(); + const { container } = render(); + + const thumb = container.querySelector('a[href="/scatter-basic"]'); + expect(thumb).toBeTruthy(); + await user.click(thumb!); + expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'featured_thumb', target: 'spec_hub', spec: 'scatter-basic' })); + }); + + it('tracks the "more in catalogue" link', async () => { + const user = userEvent.setup(); + render(); + + const more = screen.getByText(/more in the catalogue/); + await user.click(more); + expect(trackEvent).toHaveBeenCalledWith('nav_click', { source: 'specs_more_link', target: '/specs' }); + }); + + it('tracks the suggest_spec link and the okabe-ito reference', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText(/suggest/)); + expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'suggest_spec_link' })); + + await user.click(screen.getByText(/Okabe/)); + expect(trackEvent).toHaveBeenCalledWith('nav_click', expect.objectContaining({ source: 'palette_okabe_ito' })); + }); +}); diff --git a/app/src/pages/LandingPage.tsx b/app/src/pages/LandingPage.tsx index fa75c40148..78d4b28540 100644 --- a/app/src/pages/LandingPage.tsx +++ b/app/src/pages/LandingPage.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { Helmet } from 'react-helmet-async'; import Box from '@mui/material/Box'; import { Link as RouterLink, useNavigate } from 'react-router-dom'; @@ -21,7 +22,11 @@ export function LandingPage() { const potd = usePlotOfTheDay(); const featured = useFeaturedSpecs(5); const navigate = useNavigate(); - const { trackEvent } = useAnalytics(); + const { trackEvent, trackPageview } = useAnalytics(); + + useEffect(() => { + trackPageview('/'); + }, [trackPageview]); const handleLibraryClick = (lib: string) => { trackEvent('nav_click', { source: 'library_card', target: '/plots', value: lib }); diff --git a/docs/reference/plausible.md b/docs/reference/plausible.md index 9a5d9835c2..c318e6f035 100644 --- a/docs/reference/plausible.md +++ b/docs/reference/plausible.md @@ -127,7 +127,7 @@ https://anyplot.ai/{spec_id}/{language}/{library}/{category}/{value}/... | `tab_toggle` | `action`, `tab`, `library` | SpecTabs.tsx | User opens or closes a tab | | `plot_rotate` | `spec` | SpecsListPage.tsx | User clicks image on specs page to rotate library | | `open_interactive` | `spec`, `library` | SpecOverview.tsx, SpecDetailView.tsx | User opens interactive HTML view | -| `suggest_spec` | - | LandingPage.tsx (legacy) | User clicks "suggest spec" link — superseded by `nav_click` with `source: suggest_spec_link` | +| `suggest_spec` | - | SpecsListPage.tsx | User clicks the `spec.suggest()` link on the specs list page. The mirror link on the landing page emits `nav_click` with `source: suggest_spec_link` instead. | | `report_issue` | `spec`, `library`? | SpecPage.tsx | User clicks "report issue" link | | `tag_click` | `param`, `value`, `source` | SpecTabs.tsx | User clicks a tag chip to filter | | `theme_toggle` | `to` | MastheadRule.tsx | User toggles dark/light mode (`to` ∈ `dark`, `light`) | From bc88bb904ae87a9f75810a0363656e57f1e2c510 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:55:41 +0200 Subject: [PATCH 3/7] feat(docs): update commit/push policy and documentation rules - Introduce branch-scoped commit/push policy - Clarify rules for committing on main and feature branches - Emphasize confirmation for destructive operations - Reinforce documentation update requirements for changes --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c976550800..521a8c2686 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,10 @@ For detailed project documentation (architecture, commands, workflows, etc.), se ## Important Rules -- **Do NOT commit or push in interactive sessions** - When working with a user interactively, never run `git commit` or `git push` automatically. Always let the user review changes and commit/push manually. +- **Branch-scoped commit/push policy**: + - **On `main`**: NEVER commit or push directly. Always work on a feature branch. + - **On a feature branch**: Claude MAY run `git commit`, `git push`, and `gh pr create` when the work warrants it. Still respect the project's automated pipelines (see "CRITICAL: Mandatory Workflow" below) — e.g. don't manually merge spec/impl PRs. + - Confirm before destructive or hard-to-reverse operations (force-push, reset --hard, branch deletion) regardless of branch. - **GitHub Actions workflows ARE allowed to commit/push** - When running as part of `spec-*.yml` or `impl-*.yml` workflows, creating branches, commits, and PRs is expected and required. - **Always write in English** - All output text (code comments, commit messages, PR descriptions, issue comments, documentation) must be in English, even if the user writes in another language. - **Update documentation when making changes** - When adding new features, events, or modifying behavior, always check if related documentation needs updating (e.g., `docs/reference/plausible.md` for analytics events, `docs/workflows/` for workflow changes, `docs/contributing.md` for user-facing changes). From 7274075fbaf7c539be30c873d1933fe53daa2512 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:11:59 +0200 Subject: [PATCH 4/7] chore(docs): update frontend dependencies to MUI 9 - Updated MUI version in documentation across multiple files - Enhanced description of analytics data collection in LegalPage.tsx --- .github/copilot-instructions.md | 4 ++-- .serena/memories/project_overview.md | 2 +- agentic/docs/project-guide.md | 4 ++-- app/src/pages/LegalPage.tsx | 8 +++++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f397c4a718..852143f75a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -112,7 +112,7 @@ Examples: `scatter-basic`, `scatter-color-mapped`, `bar-grouped-horizontal`, `he - **`core/database/types.py`**: Custom SQLAlchemy types (PostgreSQL + SQLite compatibility) - **`core/database/repositories.py`**: Data access layer - **`api/`**: FastAPI backend (routers, schemas, dependencies, cache, analytics, MCP server) -- **`app/`**: React frontend (React 19 + TypeScript 6 + Vite 8 + MUI 7) +- **`app/`**: React frontend (React 19 + TypeScript 6 + Vite 8 + MUI 9) - **`agentic/`**: AI workflow layer (composable phases, prompt templates, runtime state) - **`automation/`**: CI/CD helper scripts (workflow_cli, label_manager, sync_to_postgres) - **`tests/unit/`**: Unit tests with mocked dependencies @@ -213,7 +213,7 @@ plt.savefig('plot.png', dpi=300, bbox_inches='tight') ## Tech Stack - **Backend**: FastAPI, SQLAlchemy (async), PostgreSQL, Python 3.14+ -- **Frontend**: React 19, TypeScript 6, Vite 8, MUI 7 +- **Frontend**: React 19, TypeScript 6, Vite 8, MUI 9 - **Package Manager**: uv (Python), yarn (Node.js) - **Linting**: Ruff (Python), ESLint (TypeScript) diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index d5a9ce1a0b..32f51dab2c 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -8,7 +8,7 @@ matplotlib, seaborn, plotly, bokeh, altair, plotnine, pygal, highcharts, lets-pl ## Tech Stack - **Backend**: Python 3.14+, FastAPI, SQLAlchemy async, asyncpg, PostgreSQL -- **Frontend**: React 19, Vite 8, TypeScript 6, MUI 7, Emotion CSS-in-JS +- **Frontend**: React 19, Vite 8, TypeScript 6, MUI 9, Emotion CSS-in-JS - **Package Managers**: uv (Python), yarn (frontend) - **Infrastructure**: Google Cloud Run, Cloud SQL, Cloud Storage (GCS) - **AI**: Claude (code generation, quality review, spec creation) diff --git a/agentic/docs/project-guide.md b/agentic/docs/project-guide.md index 0efe9a8506..a79c1099f2 100644 --- a/agentic/docs/project-guide.md +++ b/agentic/docs/project-guide.md @@ -174,7 +174,7 @@ Example: `plots/scatter-basic/` contains everything for the basic scatter plot. - **`core/database/types.py`**: Custom SQLAlchemy types (PostgreSQL + SQLite compatibility) - **`core/database/repositories.py`**: Data access layer - **`api/`**: FastAPI backend (routers, schemas, dependencies, cache, analytics, MCP server) -- **`app/`**: React frontend (Vite 8 + TypeScript 6 + MUI 7) +- **`app/`**: React frontend (Vite 8 + TypeScript 6 + MUI 9) - **`agentic/`**: AI workflow layer (composable phases, prompt templates, runtime state) - **`agentic/workflows/`**: Click CLI scripts (plan, build, test, review + orchestrators) - **`agentic/commands/`**: Markdown prompt templates @@ -327,7 +327,7 @@ gs://anyplot-images/ ## Tech Stack - **Backend**: FastAPI, SQLAlchemy (async), PostgreSQL, Python 3.14+ -- **Frontend**: React 19, Vite 8, TypeScript 6, MUI 7 +- **Frontend**: React 19, Vite 8, TypeScript 6, MUI 9 - **Plotting**: matplotlib, seaborn, plotly, bokeh, altair, plotnine, pygal, highcharts, lets-plot - **Package Manager**: uv (fast Python installer) - **Infrastructure**: Google Cloud Run, Cloud SQL, Cloud Storage diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx index 410d9791c8..8728153c4a 100644 --- a/app/src/pages/LegalPage.tsx +++ b/app/src/pages/LegalPage.tsx @@ -126,9 +126,11 @@ export function LegalPage() { , a privacy-focused analytics tool. it collects no personal data, uses no cookies, and does not track you across websites. we track: page views, navigation patterns, code copies, image - downloads, search queries, filter usage, and UI interactions. when you share a link, we detect - which platform requests the preview (e.g., LinkedIn, WhatsApp). all data is aggregated and - anonymous. + downloads, search queries, filter usage, UI interactions (tab toggles, theme preference, + banner dismissals), and anonymized performance metrics (Core Web Vitals: LCP, CLS, INP) to + keep the site fast. when you share a link, we read the requesting bot's user-agent to + detect the platform (e.g., LinkedIn, WhatsApp) — no data about the eventual viewer is + collected at that step. all data is aggregated and anonymous. public dashboard. our analytics are{' '} From 7231aa7c5aa9b725fff2a82188fcfc990827d9c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 21:14:44 +0000 Subject: [PATCH 5/7] docs(plausible): fix suggest_spec row in summary table Update the events overview table to point at SpecsListPage.tsx, matching the detail row above, and note the LandingPage mirror is attributed via nav_click with source: suggest_spec_link. https://claude.ai/code/session_015p9wn59jB3ftVNd1heffww --- docs/reference/plausible.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/plausible.md b/docs/reference/plausible.md index c318e6f035..2660df4a00 100644 --- a/docs/reference/plausible.md +++ b/docs/reference/plausible.md @@ -431,7 +431,7 @@ User lands on anyplot.ai | `tag_click` | `param`, `value`, `source` | SpecTabs.tsx | | `plot_rotate` | `spec` | SpecsListPage.tsx | | `open_interactive` | `spec`, `library` | SpecOverview.tsx, SpecDetailView.tsx | -| `suggest_spec` | - | CatalogPage.tsx | +| `suggest_spec` | - | SpecsListPage.tsx (LandingPage mirror attributed via `nav_click` with `source: suggest_spec_link`) | | `report_issue` | `spec`, `library`? | SpecPage.tsx | | `external_link` | `destination`, `spec`?, `library`? | Footer.tsx, LegalPage.tsx | | `internal_link` | `destination`, `spec`, `library` | Footer.tsx | From 8095401c23006fa33e23d172978e4eba2ae8658c Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:24:56 +0200 Subject: [PATCH 6/7] fix(analytics,seo): address remote review nits - NavBar: emit nav_logo nav_click before the modifier-key guard so cmd/ctrl/shift-click is tracked consistently with every other nav link (the guard now only protects the rapid-click debug counter). - SpecPage: harden the JSON-LD breadcrumb against injection by escaping `<` to `<` (defense-in-depth for any future SSR or snapshot path; not exploitable today). - reportWebVitals: merge ambient analytics props (e.g. theme) into LCP/CLS/INP events so the documented promise that *every* event carries the `theme` ambient prop holds for Core Web Vitals too. - useAnalytics: export getAnalyticsAmbientProps() so non-React callers outside the hook (web-vitals callbacks) can read the same snapshot. - reportWebVitals.test: cover the ambient-prop pass-through and share a single hoisted vi.mock for web-vitals to avoid cross-test interference (vi.mock dedupes by module path). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/analytics/reportWebVitals.test.ts | 42 +++++++++++++++++------ app/src/analytics/reportWebVitals.ts | 5 +++ app/src/components/NavBar.tsx | 3 +- app/src/hooks/useAnalytics.ts | 6 ++++ app/src/pages/SpecPage.tsx | 2 +- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/app/src/analytics/reportWebVitals.test.ts b/app/src/analytics/reportWebVitals.test.ts index a2b3c548e0..a9224dd198 100644 --- a/app/src/analytics/reportWebVitals.test.ts +++ b/app/src/analytics/reportWebVitals.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { reportWebVitals } from './reportWebVitals'; +import { setAnalyticsAmbientProps } from '../hooks/useAnalytics'; + +// Single hoisted mock (vi.mock dedupes by module path — last call wins, so +// keeping one shared mock avoids cross-test interference). +vi.mock('web-vitals', () => ({ + onLCP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 2500, rating: 'good' }), + onCLS: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 0.15, rating: 'needs-improvement' }), + onINP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 200, rating: 'good' }), +})); describe('reportWebVitals', () => { const originalLocation = window.location; @@ -11,6 +20,7 @@ describe('reportWebVitals', () => { configurable: true, }); delete window.plausible; + setAnalyticsAmbientProps({ theme: '' }); }); it('does nothing in non-production environment', () => { @@ -44,16 +54,7 @@ describe('reportWebVitals', () => { }); window.plausible = vi.fn(); - // Mock the dynamic import - vi.mock('web-vitals', () => ({ - onLCP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 2500, rating: 'good' }), - onCLS: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 0.15, rating: 'needs-improvement' }), - onINP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 200, rating: 'good' }), - })); - reportWebVitals(); - - // Wait for dynamic import to resolve await vi.dynamicImportSettled(); expect(window.plausible).toHaveBeenCalledWith('LCP', { @@ -65,7 +66,28 @@ describe('reportWebVitals', () => { expect(window.plausible).toHaveBeenCalledWith('INP', { props: { value: '200', rating: 'good' }, }); + }); + + it('merges ambient analytics props (e.g. theme) into CWV events', async () => { + Object.defineProperty(window, 'location', { + value: { ...originalLocation, hostname: 'anyplot.ai' }, + writable: true, + configurable: true, + }); + window.plausible = vi.fn(); + setAnalyticsAmbientProps({ theme: 'dark' }); - vi.restoreAllMocks(); + reportWebVitals(); + await vi.dynamicImportSettled(); + + expect(window.plausible).toHaveBeenCalledWith('LCP', { + props: { theme: 'dark', value: '2500', rating: 'good' }, + }); + expect(window.plausible).toHaveBeenCalledWith('CLS', { + props: { theme: 'dark', value: '0.15', rating: 'needs-improvement' }, + }); + expect(window.plausible).toHaveBeenCalledWith('INP', { + props: { theme: 'dark', value: '200', rating: 'good' }, + }); }); }); diff --git a/app/src/analytics/reportWebVitals.ts b/app/src/analytics/reportWebVitals.ts index 74b2478df5..51d02c508a 100644 --- a/app/src/analytics/reportWebVitals.ts +++ b/app/src/analytics/reportWebVitals.ts @@ -1,3 +1,5 @@ +import { getAnalyticsAmbientProps } from '../hooks/useAnalytics'; + /** * Core Web Vitals tracking via web-vitals library. * Reports LCP, CLS, and INP to Plausible as custom events. @@ -15,6 +17,7 @@ export function reportWebVitals() { onLCP((metric) => { window.plausible?.('LCP', { props: { + ...getAnalyticsAmbientProps(), value: String(Math.round(metric.value / 100) * 100), rating: metric.rating, }, @@ -24,6 +27,7 @@ export function reportWebVitals() { onCLS((metric) => { window.plausible?.('CLS', { props: { + ...getAnalyticsAmbientProps(), value: String(Math.round(metric.value * 100) / 100), rating: metric.rating, }, @@ -33,6 +37,7 @@ export function reportWebVitals() { onINP((metric) => { window.plausible?.('INP', { props: { + ...getAnalyticsAmbientProps(), value: String(Math.round(metric.value / 50) * 50), rating: metric.rating, }, diff --git a/app/src/components/NavBar.tsx b/app/src/components/NavBar.tsx index cdfb197760..9e9458fe04 100644 --- a/app/src/components/NavBar.tsx +++ b/app/src/components/NavBar.tsx @@ -76,6 +76,7 @@ export function NavBar() { // Non-triggering clicks fall through to RouterLink's normal `/` navigation. const handleLogoClick = useCallback( (e: React.MouseEvent) => { + trackEvent('nav_click', { source: 'nav_logo', target: '/' }); if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return; clickCountRef.current += 1; if (clickTimerRef.current) clearTimeout(clickTimerRef.current); @@ -85,9 +86,7 @@ export function NavBar() { clickCountRef.current = 0; if (clickTimerRef.current) clearTimeout(clickTimerRef.current); navigate('/debug'); - return; } - trackEvent('nav_click', { source: 'nav_logo', target: '/' }); }, [navigate, trackEvent] ); diff --git a/app/src/hooks/useAnalytics.ts b/app/src/hooks/useAnalytics.ts index 9217a5d19d..e9aff89800 100644 --- a/app/src/hooks/useAnalytics.ts +++ b/app/src/hooks/useAnalytics.ts @@ -19,6 +19,12 @@ export function setAnalyticsAmbientProps(props: Record): void { ambientProps = merged; } +// Snapshot of current ambient props for callers that fire `window.plausible` +// directly (e.g. reportWebVitals.ts, which runs outside React). +export function getAnalyticsAmbientProps(): Record { + return { ...ambientProps }; +} + function debounce void>( fn: T, delay: number, diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index 51bf67c179..dd50a5df0a 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -354,7 +354,7 @@ export function SpecPage() { {currentImpl?.preview_url && } - + From e8709d5579b1e0b2b342ce966373f32a964a9211 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:26:07 +0200 Subject: [PATCH 7/7] chore(docs): finish MUI 9 sweep in audit command The audit command's frontend-auditor scope still pointed at "MUI 7 patterns". This finishes the MUI 9 sweep started in 7274075f, which already updated project-guide.md, copilot-instructions.md, the project_overview Serena memory, and LegalPage.tsx. Co-Authored-By: Claude Opus 4.7 (1M context) --- agentic/commands/audit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentic/commands/audit.md b/agentic/commands/audit.md index 08f83aee7c..94c5fa72d9 100644 --- a/agentic/commands/audit.md +++ b/agentic/commands/audit.md @@ -159,7 +159,7 @@ You are the **frontend-auditor** on the audit team. Analyze the `app/src/` direc - **Hooks**: Custom hook patterns, missing dependency arrays, stale closures, unnecessary re-renders - **Performance**: Missing `memo`/`useMemo`/`useCallback` where needed, large bundles, unnecessary renders - **Accessibility**: Missing aria-labels, keyboard navigation, focus management, color contrast -- **MUI 7 patterns**: Correct theme usage, sx prop vs styled, consistent component usage +- **MUI 9 patterns**: Correct theme usage, sx prop vs styled, consistent component usage - **Dead code**: Unused components, unused imports, unreachable code, commented-out code - **Error handling**: Error boundaries, loading states, empty states, fallbacks - **Consistency**: Naming conventions, file organization, export patterns