diff --git a/.github/workflows/spec-create.yml b/.github/workflows/spec-create.yml
index 8a9d11055b..77804d4488 100644
--- a/.github/workflows/spec-create.yml
+++ b/.github/workflows/spec-create.yml
@@ -297,7 +297,7 @@ jobs:
fi
# Reserved spec slugs collide with top-level frontend routes.
- # Keep this list in sync with `RESERVED_TOP_LEVEL` in app/src/utils/paths.ts.
+ # Keep this list in sync with `RESERVED_TOP_LEVEL` in app/src/routes/paths.ts.
RESERVED_SLUGS=(plots specs libraries palette about legal mcp stats debug map api og sitemap.xml robots.txt)
for reserved in "${RESERVED_SLUGS[@]}"; do
if [[ "$SPEC_ID" == "$reserved" ]]; then
diff --git a/app/src/app.tsx b/app/src/app.tsx
index f2fc46313a..0c4bb9c84f 100644
--- a/app/src/app.tsx
+++ b/app/src/app.tsx
@@ -1,7 +1,7 @@
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
-import { AppRouter } from 'src/router';
+import { AppRouter } from 'src/routes';
import { theme } from 'src/theme';
export function App() {
diff --git a/app/src/components/FeedbackWidget.tsx b/app/src/components/FeedbackWidget.tsx
index f093cf6c27..e21ec8b249 100644
--- a/app/src/components/FeedbackWidget.tsx
+++ b/app/src/components/FeedbackWidget.tsx
@@ -19,7 +19,7 @@ import Tooltip from '@mui/material/Tooltip';
import { useAnalytics } from 'src/hooks';
import { useLocalStorage } from 'src/hooks/useLocalStorage';
import { apiPost, endpoints } from 'src/lib/api';
-import { RESERVED_TOP_LEVEL } from 'src/utils/paths';
+import { RESERVED_TOP_LEVEL } from 'src/routes/paths';
const MAX_MESSAGE_LENGTH = 500;
const SESSION_KEY = 'anyplot_feedback_session';
diff --git a/app/src/components/Footer.tsx b/app/src/components/Footer.tsx
index 12397d6eba..0f5d0ceba0 100644
--- a/app/src/components/Footer.tsx
+++ b/app/src/components/Footer.tsx
@@ -4,6 +4,7 @@ import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import { GITHUB_URL } from 'src/constants';
+import { paths } from 'src/routes/paths';
import { colors, fontSize, semanticColors, typography } from 'src/theme';
interface FooterProps {
@@ -92,11 +93,11 @@ export function Footer({ onTrackEvent, selectedSpec, selectedLibrary }: FooterPr
·
-
+
about
·
-
+
legal
diff --git a/app/src/components/HeroSection.tsx b/app/src/components/HeroSection.tsx
index 2585bd9074..c3a185de08 100644
--- a/app/src/components/HeroSection.tsx
+++ b/app/src/components/HeroSection.tsx
@@ -6,6 +6,7 @@ import { PlotOfTheDayTerminal } from 'src/components/PlotOfTheDayTerminal';
import { TypewriterText } from 'src/components/TypewriterText';
import { useAnalytics } from 'src/hooks';
import type { PlotOfTheDayData } from 'src/hooks/usePlotOfTheDay';
+import { paths } from 'src/routes/paths';
import { colors, typography } from 'src/theme';
interface HeroSectionProps {
@@ -170,11 +171,13 @@ export function HeroSection({ potd = null }: HeroSectionProps) {
}}
>
trackEvent('nav_click', { source: 'hero_cta_browse', target: '/plots' })}
+ onClick={() =>
+ trackEvent('nav_click', { source: 'hero_cta_browse', target: paths.plots })
+ }
/>
trackEvent('nav_click', { source: 'hero_mcp', target: '/mcp' })}
+ onClick={() => trackEvent('nav_click', { source: 'hero_mcp', target: paths.mcp })}
/>
trackEvent('nav_click', { source: 'masthead_logo', target: '/' })}
+ to={paths.home}
+ onClick={() => trackEvent('nav_click', { source: 'masthead_logo', target: paths.home })}
sx={{ ...linkSx, display: rootMarkerDisplay }}
>
~/anyplot.ai
diff --git a/app/src/components/NavBar.tsx b/app/src/components/NavBar.tsx
index fd69973d1d..913e469a8b 100644
--- a/app/src/components/NavBar.tsx
+++ b/app/src/components/NavBar.tsx
@@ -5,6 +5,7 @@ import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import { useAnalytics } from 'src/hooks';
+import { paths } from 'src/routes/paths';
import { colors, typography } from 'src/theme';
const DEBUG_CLICK_COUNT = 5;
@@ -72,15 +73,15 @@ export function NavBar() {
}, []);
const handleSearch = () => {
- trackEvent('nav_click', { source: 'nav_search', target: '/plots?focus=search' });
- navigate('/plots?focus=search');
+ trackEvent('nav_click', { source: 'nav_search', target: paths.plotsSearch });
+ navigate(paths.plotsSearch);
};
// 5 rapid clicks on the logo opens /debug.
// Non-triggering clicks fall through to RouterLink's normal `/` navigation.
const handleLogoClick = useCallback(
(e: React.MouseEvent) => {
- trackEvent('nav_click', { source: 'nav_logo', target: '/' });
+ trackEvent('nav_click', { source: 'nav_logo', target: paths.home });
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
clickCountRef.current += 1;
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
@@ -91,7 +92,7 @@ export function NavBar() {
e.preventDefault();
clickCountRef.current = 0;
if (clickTimerRef.current) clearTimeout(clickTimerRef.current);
- navigate('/debug');
+ navigate(paths.debug);
}
},
[navigate, trackEvent]
@@ -119,7 +120,7 @@ export function NavBar() {
{/* Logo */}
}
sx={{ textTransform: 'none' }}
diff --git a/app/src/components/ScienceNote.tsx b/app/src/components/ScienceNote.tsx
index b747c9a299..6b0c03a5fa 100644
--- a/app/src/components/ScienceNote.tsx
+++ b/app/src/components/ScienceNote.tsx
@@ -3,6 +3,7 @@ import { Link as RouterLink } from 'react-router-dom';
import Box from '@mui/material/Box';
import { PaletteStrip } from 'src/components/PaletteStrip';
+import { paths } from 'src/routes/paths';
import { colors, typography } from 'src/theme';
export function ScienceNote() {
@@ -94,7 +95,7 @@ export function ScienceNote() {
import('src/components/CodeHighlighter'));
import { apiGet, endpoints } from 'src/lib/api';
import { colors, fontSize, semanticColors, typography } from 'src/theme';
@@ -231,7 +233,7 @@ export function SpecTabs({
const handleTagClick = useCallback(
(paramName: string, value: string) => {
onTrackEvent?.('tag_click', { param: paramName, value, source: 'spec_detail' });
- navigate(`/plots?${paramName}=${encodeURIComponent(value)}`);
+ navigate(paths.plotsFiltered(paramName, value));
},
[navigate, onTrackEvent]
);
diff --git a/app/src/components/ToolbarActions.tsx b/app/src/components/ToolbarActions.tsx
index dc04558c40..fc66be6a8d 100644
--- a/app/src/components/ToolbarActions.tsx
+++ b/app/src/components/ToolbarActions.tsx
@@ -12,6 +12,7 @@ import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import type { ImageSize } from 'src/constants';
+import { paths } from 'src/routes/paths';
import { colors, semanticColors } from 'src/theme';
interface ToolbarActionsProps {
@@ -28,7 +29,7 @@ export function PlotsLink() {
{
- trackPageview('/about');
+ trackPageview(paths.about);
}, [trackPageview]);
return (
@@ -108,7 +109,7 @@ export function AboutPage() {
see the{' '}
trackEvent('internal_link', { destination: 'palette', source: 'about' })
}
@@ -153,7 +154,7 @@ export function AboutPage() {
curious about the stack, costs, or analytics? see{' '}
trackEvent('internal_link', {
destination: 'legal_transparency',
diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx
index 2cfe54f2dc..b82cfd379d 100644
--- a/app/src/pages/DebugPage.tsx
+++ b/app/src/pages/DebugPage.tsx
@@ -14,9 +14,9 @@ import { SectionHeader } from 'src/components/SectionHeader';
import { DEBUG_API_URL, LIB_ABBREV, LIB_TO_LANG, LIBRARIES } from 'src/constants';
import { useCopyCode } from 'src/hooks';
import { fetchWithAuth } from 'src/lib/api';
+import { specPath } from 'src/routes/paths';
import { colors, fontSize, semanticColors, typography } from 'src/theme';
import { buildClaudePrompt } from 'src/utils/claudePrompt';
-import { specPath } from 'src/utils/paths';
// ============================================================================
// Types
diff --git a/app/src/pages/LandingPage.tsx b/app/src/pages/LandingPage.tsx
index fc0dc58290..76ff184706 100644
--- a/app/src/pages/LandingPage.tsx
+++ b/app/src/pages/LandingPage.tsx
@@ -14,8 +14,8 @@ import { useAnalytics, useAppData } from 'src/hooks';
import { type FeaturedImpl, useFeaturedSpecs } from 'src/hooks/useFeaturedSpecs';
import { useTheme } from 'src/hooks/useLayoutContext';
import { usePlotOfTheDay } from 'src/hooks/usePlotOfTheDay';
+import { paths, specPath } from 'src/routes/paths';
import { colors, semanticColors, typography } from 'src/theme';
-import { specPath } from 'src/utils/paths';
import { buildSrcSet, getFallbackSrc } from 'src/utils/responsiveImage';
import { selectPreviewUrl } from 'src/utils/themedPreview';
@@ -31,8 +31,8 @@ export function LandingPage() {
}, [trackPageview]);
const handleLibraryClick = (lib: string) => {
- trackEvent('nav_click', { source: 'library_card', target: '/plots', value: lib });
- navigate(`/plots?lib=${encodeURIComponent(lib)}`);
+ trackEvent('nav_click', { source: 'library_card', target: paths.plots, value: lib });
+ navigate(paths.plotsFiltered('lib', lib));
};
return (
@@ -117,8 +117,10 @@ function MapSection({ specCount }: { specCount?: number }) {
trackEvent('nav_click', { source: 'map_teaser_preview', target: '/map' })}
+ to={paths.map}
+ onClick={() =>
+ trackEvent('nav_click', { source: 'map_teaser_preview', target: paths.map })
+ }
sx={{
display: 'block',
textDecoration: 'none',
@@ -475,8 +477,10 @@ function SpecsSection({
{specCount != null && featured && featured.length < specCount && (
trackEvent('nav_click', { source: 'specs_more_link', target: '/specs' })}
+ to={paths.specs}
+ onClick={() =>
+ trackEvent('nav_click', { source: 'specs_more_link', target: paths.specs })
+ }
sx={{
display: 'inline-block',
mt: 2.5,
diff --git a/app/src/pages/LibrariesPage.tsx b/app/src/pages/LibrariesPage.tsx
index ee8bb5d34f..f7fdd091ce 100644
--- a/app/src/pages/LibrariesPage.tsx
+++ b/app/src/pages/LibrariesPage.tsx
@@ -10,6 +10,7 @@ import { LibraryCard } from 'src/components/LibraryCard';
import { SectionHeader } from 'src/components/SectionHeader';
import { LIB_TO_FRAMEWORK, LIBRARIES } from 'src/constants';
import { useAnalytics, useAppData } from 'src/hooks';
+import { paths } from 'src/routes/paths';
import { colors, textStyle, typography } from 'src/theme';
// Framework filter (per library-expansion.md §6: "all JavaScript libs" vs
@@ -48,7 +49,7 @@ export function LibrariesPage() {
const [frameworkFilter, setFrameworkFilter] = useState('all');
useEffect(() => {
- trackPageview('/libraries');
+ trackPageview(paths.libraries);
}, [trackPageview]);
const byId = new Map(librariesData.map(lib => [lib.id, lib]));
@@ -60,7 +61,7 @@ export function LibrariesPage() {
const handleLibraryClick = (name: string) => {
trackEvent('library_click', { source: 'libraries_page', library: name });
- navigate(`/plots?lib=${name}`);
+ navigate(paths.plotsFiltered('lib', name));
};
const handleFilterClick = (id: FrameworkFilter) => {
diff --git a/app/src/pages/MapPage.tsx b/app/src/pages/MapPage.tsx
index 48aeb32202..af1360791a 100644
--- a/app/src/pages/MapPage.tsx
+++ b/app/src/pages/MapPage.tsx
@@ -37,8 +37,8 @@ import {
type TagCategory,
topCategoryValues,
} from 'src/pages/MapPage.helpers';
+import { specPath } from 'src/routes/paths';
import { colors, fontSize, typography } from 'src/theme';
-import { specPath } from 'src/utils/paths';
const NODE_SIZE = 60; // graph-space size of a node — large enough to read the thumbnail without hovering
const COOLDOWN_TICKS = 300; // simulation lifetime in ticks; the engine cap and alpha-decay below both derive from this so they stop together
diff --git a/app/src/pages/NotFoundPage.tsx b/app/src/pages/NotFoundPage.tsx
index ae508361ca..bb6ca64534 100644
--- a/app/src/pages/NotFoundPage.tsx
+++ b/app/src/pages/NotFoundPage.tsx
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
+import { paths } from 'src/routes/paths';
import { colors, semanticColors, typography } from 'src/theme';
export function NotFoundPage() {
@@ -35,7 +36,7 @@ export function NotFoundPage() {
{
- trackPageview('/palette');
+ trackPageview(paths.palette);
}, [trackPageview]);
const sortedPalette: Swatch[] = useMemo(
diff --git a/app/src/pages/PlotsPage.tsx b/app/src/pages/PlotsPage.tsx
index fd5313be44..c08620b636 100644
--- a/app/src/pages/PlotsPage.tsx
+++ b/app/src/pages/PlotsPage.tsx
@@ -13,9 +13,9 @@ import { ImagesGrid } from 'src/components/ImagesGrid';
import type { ImageSize } from 'src/constants';
import { isFiltersEmpty, useAnalytics, useFilterState, useInfiniteScroll } from 'src/hooks';
import { useAppData, useHomeState } from 'src/hooks';
+import { specPath } from 'src/routes/paths';
import { colors } from 'src/theme';
import type { PlotImage } from 'src/types';
-import { specPath } from 'src/utils/paths';
export function PlotsPage() {
const navigate = useNavigate();
diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx
index e8cd6e06ab..80e79406fb 100644
--- a/app/src/pages/SpecPage.tsx
+++ b/app/src/pages/SpecPage.tsx
@@ -16,8 +16,8 @@ import { useAnalytics, useCodeFetch } from 'src/hooks';
import { useAppData } from 'src/hooks';
import { ApiError, apiGet, apiUrl, endpoints } from 'src/lib/api';
import { NotFoundPage } from 'src/pages/NotFoundPage';
+import { paths, specPath } from 'src/routes/paths';
import { colors, fontSize, semanticColors, typography } from 'src/theme';
-import { specPath } from 'src/utils/paths';
const SpecTabs = lazy(() => import('src/components/SpecTabs').then(m => ({ default: m.SpecTabs })));
const SpecOverview = lazy(() =>
@@ -372,7 +372,7 @@ export function SpecPage() {
}
sx={{ color: colors.primary }}
>
diff --git a/app/src/pages/SpecsListPage.tsx b/app/src/pages/SpecsListPage.tsx
index 7e81c4a992..1508159f00 100644
--- a/app/src/pages/SpecsListPage.tsx
+++ b/app/src/pages/SpecsListPage.tsx
@@ -14,9 +14,9 @@ import { GITHUB_URL } from 'src/constants';
import { useAnalytics } from 'src/hooks';
import { useAppData, useHomeState } from 'src/hooks';
import { ApiError, apiGet, endpoints } from 'src/lib/api';
+import { paths, specPath } from 'src/routes/paths';
import { colors, fontSize, semanticColors, typography } from 'src/theme';
import type { PlotImage } from 'src/types';
-import { specPath } from 'src/utils/paths';
import { buildSrcSet, getFallbackSrc, SPECS_SIZES } from 'src/utils/responsiveImage';
interface SpecListItem {
@@ -41,7 +41,7 @@ export function SpecsListPage() {
// Track specs page view
useEffect(() => {
- trackPageview('/specs');
+ trackPageview(paths.specs);
}, [trackPageview]);
const [allImages, setAllImages] = useState([]);
diff --git a/app/src/pages/StatsPage.tsx b/app/src/pages/StatsPage.tsx
index 5da9c59ad0..a5c91e1c44 100644
--- a/app/src/pages/StatsPage.tsx
+++ b/app/src/pages/StatsPage.tsx
@@ -12,8 +12,8 @@ import { SectionHeader } from 'src/components/SectionHeader';
import { useAnalytics } from 'src/hooks';
import { useTheme } from 'src/hooks/useLayoutContext';
import { ApiError, apiGet, endpoints } from 'src/lib/api';
+import { paths, specPath } from 'src/routes/paths';
import { colors, fontSize, semanticColors, typography } from 'src/theme';
-import { specPath } from 'src/utils/paths';
import { buildSrcSet, getFallbackSrc } from 'src/utils/responsiveImage';
import { selectPreviewUrl } from 'src/utils/themedPreview';
@@ -108,7 +108,7 @@ export function StatsPage() {
const [error, setError] = useState(null);
useEffect(() => {
- trackPageview('/stats');
+ trackPageview(paths.stats);
}, [trackPageview]);
useEffect(() => {
@@ -793,7 +793,7 @@ export function StatsPage() {
{
if (param)
trackEvent('tag_click', { param, value: tag, source: 'stats' });
diff --git a/app/src/router.tsx b/app/src/routes/index.tsx
similarity index 100%
rename from app/src/router.tsx
rename to app/src/routes/index.tsx
diff --git a/app/src/routes/paths.test.ts b/app/src/routes/paths.test.ts
new file mode 100644
index 0000000000..cea56928f5
--- /dev/null
+++ b/app/src/routes/paths.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+
+import { langFromPath, paths, RESERVED_TOP_LEVEL, specPath } from 'src/routes/paths';
+
+describe('specPath', () => {
+ it('builds the cross-language hub path', () => {
+ expect(specPath('scatter-basic')).toBe('/scatter-basic');
+ });
+
+ it('builds the language overview path', () => {
+ expect(specPath('scatter-basic', 'python')).toBe('/scatter-basic/python');
+ });
+
+ it('builds the implementation detail path', () => {
+ expect(specPath('scatter-basic', 'python', 'matplotlib')).toBe(
+ '/scatter-basic/python/matplotlib'
+ );
+ });
+
+ it('ignores library without language', () => {
+ expect(specPath('scatter-basic', undefined, 'matplotlib')).toBe('/scatter-basic');
+ });
+});
+
+describe('langFromPath', () => {
+ it('returns the language segment of a spec path', () => {
+ expect(langFromPath('/scatter-basic/python')).toBe('python');
+ });
+
+ it('returns undefined for single-segment paths', () => {
+ expect(langFromPath('/scatter-basic')).toBeUndefined();
+ });
+
+ it('returns undefined when the first segment is a reserved route', () => {
+ expect(langFromPath('/plots/python')).toBeUndefined();
+ });
+});
+
+describe('paths registry', () => {
+ it('exposes every static route', () => {
+ expect(paths.home).toBe('/');
+ expect(paths.plots).toBe('/plots');
+ expect(paths.specs).toBe('/specs');
+ expect(paths.libraries).toBe('/libraries');
+ expect(paths.map).toBe('/map');
+ expect(paths.palette).toBe('/palette');
+ expect(paths.about).toBe('/about');
+ expect(paths.legal).toBe('/legal');
+ expect(paths.mcp).toBe('/mcp');
+ expect(paths.stats).toBe('/stats');
+ expect(paths.debug).toBe('/debug');
+ });
+
+ it('every static route is a reserved top-level slug', () => {
+ const staticRoutes = [
+ paths.plots,
+ paths.specs,
+ paths.libraries,
+ paths.map,
+ paths.palette,
+ paths.about,
+ paths.legal,
+ paths.mcp,
+ paths.stats,
+ paths.debug,
+ ];
+ for (const route of staticRoutes) {
+ expect(RESERVED_TOP_LEVEL.has(route.slice(1))).toBe(true);
+ }
+ });
+
+ it('builds encoded plots filter URLs', () => {
+ expect(paths.plotsFiltered('lib', 'ggplot2')).toBe('/plots?lib=ggplot2');
+ expect(paths.plotsFiltered('plot', 'scatter plot')).toBe('/plots?plot=scatter%20plot');
+ });
+
+ it('exposes specPath as paths.spec', () => {
+ expect(paths.spec('heatmap-basic', 'r', 'ggplot2')).toBe('/heatmap-basic/r/ggplot2');
+ });
+});
diff --git a/app/src/utils/paths.ts b/app/src/routes/paths.ts
similarity index 66%
rename from app/src/utils/paths.ts
rename to app/src/routes/paths.ts
index f5c2d906ee..0af6b5fcee 100644
--- a/app/src/utils/paths.ts
+++ b/app/src/routes/paths.ts
@@ -43,3 +43,25 @@ export function langFromPath(pathname: string): string | undefined {
if (RESERVED_TOP_LEVEL.has(segments[0])) return undefined;
return segments[1];
}
+
+/**
+ * Central route registry — the single source of truth for app URLs.
+ * Components navigate via `paths.*` instead of hardcoded strings; spec-detail
+ * URLs go through `specPath` (also exposed as `paths.spec`).
+ */
+export const paths = {
+ home: '/',
+ about: '/about',
+ debug: '/debug',
+ legal: '/legal',
+ libraries: '/libraries',
+ map: '/map',
+ mcp: '/mcp',
+ palette: '/palette',
+ plots: '/plots',
+ plotsSearch: '/plots?focus=search',
+ specs: '/specs',
+ stats: '/stats',
+ plotsFiltered: (param: string, value: string) => `/plots?${param}=${encodeURIComponent(value)}`,
+ spec: specPath,
+} as const;
diff --git a/app/vitest.config.ts b/app/vitest.config.ts
index eb6c78d46c..b4abd2db55 100644
--- a/app/vitest.config.ts
+++ b/app/vitest.config.ts
@@ -30,6 +30,7 @@ export default defineConfig({
'src/components/**/*.tsx',
'src/pages/**/*.tsx',
'src/constants/**/*.ts',
+ 'src/routes/**/*.ts',
'src/theme/**/*.ts',
'src/types/**/*.ts',
],
diff --git a/docs/reference/seo.md b/docs/reference/seo.md
index 3a7484e082..6347fd72f1 100644
--- a/docs/reference/seo.md
+++ b/docs/reference/seo.md
@@ -354,7 +354,7 @@ without the query string.
### Reserved Spec Slugs
Spec IDs are top-level path segments, so they must not collide with reserved
-routes. The blocklist is enforced at runtime in `app/src/utils/paths.ts`
+routes. The blocklist is enforced at runtime in `app/src/routes/paths.ts`
(`RESERVED_TOP_LEVEL`) and at spec creation time in `.github/workflows/spec-create.yml`:
```
@@ -389,7 +389,7 @@ changing the canonical.
### Path Utility
-Frontend URL generation is centralized in `app/src/utils/paths.ts`:
+Frontend URL generation is centralized in `app/src/routes/paths.ts`:
- `specPath(specId, language?, library?)` — builds the three-tier URL based on
which arguments are provided.
- `langFromPath(pathname)` — extracts the language segment from a path.