diff --git a/agentic/commands/audit/design-auditor.md b/agentic/commands/audit/design-auditor.md index 4bd26e6907..b82d9b00b2 100644 --- a/agentic/commands/audit/design-auditor.md +++ b/agentic/commands/audit/design-auditor.md @@ -15,7 +15,7 @@ Almost every finding you emit is `DIMENSION: looks`. Tag the rare exception accu - **Exemplary gap**: what would a best-in-class product site have here that anyplot lacks (a polished design system, motion/micro-interactions, a cohesive empty-state language)? Emit high-value gaps as `looks` findings. **How to work:** -1. Glob `app/src/theme/**`, `app/src/components/**`, `app/src/pages/**`, `app/src/styles/**` +1. Glob `app/src/theme/**`, `app/src/components/**`, `app/src/sections/**`, `app/src/layouts/**`, `app/src/pages/**`, `app/src/styles/**` 2. Read the theme definition(s) and `useThemeMode` to learn the token system and how light/dark are derived 3. Grep for hardcoded visual values that bypass the theme: `#[0-9a-fA-F]{3,8}`, `rgb\(`, `style=\{\{`, `px`-literal sizing in `sx`, `color:\s*['"]`, `backgroundColor` 4. Read a representative set of pages/components to see how consistently theme tokens vs. literals are used diff --git a/app/src/layouts/BareLayout.tsx b/app/src/layouts/BareLayout.tsx deleted file mode 100644 index b1845f2f3e..0000000000 --- a/app/src/layouts/BareLayout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Outlet } from 'react-router-dom'; - -import Box from '@mui/material/Box'; - -/** - * Layout for routes that opt out of the global chrome — interactive plot - * embeds, debug surfaces. Pages own their full viewport. - */ -export function BareLayout() { - return ( - - - - ); -} diff --git a/app/src/layouts/index.ts b/app/src/layouts/index.ts index 79d77bb75d..991a275a6e 100644 --- a/app/src/layouts/index.ts +++ b/app/src/layouts/index.ts @@ -1,4 +1,3 @@ -export * from 'src/layouts/BareLayout'; export * from 'src/layouts/Footer'; export * from 'src/layouts/Layout'; export * from 'src/layouts/MastheadRule'; diff --git a/app/src/sections/landing/PaletteStrip.tsx b/app/src/sections/landing/PaletteStrip.tsx deleted file mode 100644 index 9b42b6656a..0000000000 --- a/app/src/sections/landing/PaletteStrip.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import Box from '@mui/material/Box'; - -// imprint palette — 8 categorical hues in hybrid-v3 sort order -const DEFAULT_SWATCHES = [ - '#009E73', - '#C475FD', - '#4467A3', - '#BD8233', - '#AE3030', - '#2ABCCD', - '#954477', - '#99B314', -]; - -interface PaletteStripProps { - /** Maximum width in pixels (default 400). Set to `null` for no cap. */ - maxWidth?: number | null; - /** Strip height in pixels (default 40). */ - height?: number; - /** Top margin (MUI spacing units, default 5). */ - mt?: number; - /** Override the default 8-hex imprint set (e.g. to render an alternate sort). */ - hexes?: string[]; -} - -export function PaletteStrip({ - maxWidth = 400, - height = 40, - mt = 5, - hexes, -}: PaletteStripProps = {}) { - const SWATCHES = hexes ?? DEFAULT_SWATCHES; - return ( - - {SWATCHES.map((color, i) => ( - - ))} - - ); -} diff --git a/app/src/sections/landing/PlotOfTheDay.test.tsx b/app/src/sections/landing/PlotOfTheDay.test.tsx deleted file mode 100644 index 277dbc4720..0000000000 --- a/app/src/sections/landing/PlotOfTheDay.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { render, screen, waitFor } from 'src/test-utils'; - -const trackEvent = vi.fn(); - -vi.mock('src/hooks', async () => { - const actual = await vi.importActual('src/hooks'); - return { - ...actual, - useAnalytics: () => ({ trackEvent, trackPageview: vi.fn() }), - }; -}); - -import { PlotOfTheDay } from 'src/sections/landing/PlotOfTheDay'; - -// Mock sessionStorage -const sessionStorageMock: Record = {}; -const sessionStorageStub = { - getItem: vi.fn((key: string) => sessionStorageMock[key] ?? null), - setItem: vi.fn((key: string, value: string) => { - sessionStorageMock[key] = value; - }), - removeItem: vi.fn((key: string) => { - delete sessionStorageMock[key]; - }), - clear: vi.fn(() => { - Object.keys(sessionStorageMock).forEach(k => delete sessionStorageMock[k]); - }), - get length() { - return Object.keys(sessionStorageMock).length; - }, - key: vi.fn(() => null), -}; - -const mockData = { - spec_id: 'scatter-basic', - spec_title: 'Basic Scatter Plot', - description: 'A scatter plot', - library_id: 'matplotlib', - library_name: 'Matplotlib', - language: 'python', - quality_score: 9, - preview_url: 'https://cdn.example.com/plots/scatter-basic/matplotlib/plot.png', - image_description: 'Shows data points with clear labels', - library_version: '3.8.0', - python_version: '3.12', - language_version: '3.12', - date: '2026-04-11', -}; - -describe('PlotOfTheDay', () => { - let fetchMock: ReturnType; - - beforeEach(() => { - fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - // Reset sessionStorage mock state - Object.keys(sessionStorageMock).forEach(k => delete sessionStorageMock[k]); - vi.stubGlobal('sessionStorage', sessionStorageStub); - sessionStorageStub.getItem.mockClear(); - sessionStorageStub.setItem.mockClear(); - trackEvent.mockClear(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('renders a placeholder while loading', () => { - // Never resolve the fetch, so component stays in loading state - fetchMock.mockReturnValue(new Promise(() => {})); - - const { container } = render(); - - // The loading state renders a Box with minHeight for CLS prevention - const placeholder = container.firstChild as HTMLElement; - expect(placeholder).toBeInTheDocument(); - // Should not show any text content yet - expect(screen.queryByText('plot of the day')).not.toBeInTheDocument(); - }); - - it('shows the card after successful fetch', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockData), - }); - - render(); - - await waitFor(() => { - expect(screen.getByText('plot of the day')).toBeInTheDocument(); - }); - - expect(screen.getByText('Basic Scatter Plot')).toBeInTheDocument(); - }); - - it('shows image description when present', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockData), - }); - - render(); - - await waitFor(() => { - expect(screen.getByText(/Shows data points with clear labels/)).toBeInTheDocument(); - }); - }); - - it('shows library version and python version in bottom bar', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockData), - }); - - render(); - - await waitFor(() => { - expect(screen.getByText(/Matplotlib 3\.8\.0/)).toBeInTheDocument(); - expect(screen.getByText(/Python 3\.12/)).toBeInTheDocument(); - }); - }); - - it('returns null immediately when dismissed via sessionStorage', () => { - sessionStorageMock['potd_dismissed'] = 'true'; - - const { container } = render(); - - // Dismissed state returns null — no DOM output - expect(container.firstChild).toBeNull(); - // Should not have fetched - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('returns null after API error', async () => { - fetchMock.mockResolvedValueOnce({ ok: false }); - - const { container } = render(); - - await waitFor(() => { - // After loading finishes with error, data is null so component returns null - expect(container.firstChild).toBeNull(); - }); - }); - - it('dismisses on close button click', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockData), - }); - - const { userEvent } = await import('src/test-utils'); - const user = userEvent.setup(); - - const { container } = render(); - - await waitFor(() => { - expect(screen.getByText('plot of the day')).toBeInTheDocument(); - }); - - const dismissButton = screen.getByLabelText('Dismiss plot of the day'); - await user.click(dismissButton); - - // 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('src/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' }) - ); - - const sourceLink = screen.getByText(/python plots\/scatter-basic\/matplotlib\.py/); - expect(sourceLink.getAttribute('href')).toMatch( - /\/blob\/main\/plots\/scatter-basic\/implementations\/python\/matplotlib\.py$/ - ); - await user.click(sourceLink); - expect(trackEvent).toHaveBeenCalledWith( - 'nav_click', - expect.objectContaining({ source: 'potd_source_link' }) - ); - }); - - it('hides library version when it is "unknown"', async () => { - const dataWithUnknownVersion = { ...mockData, library_version: 'unknown' }; - fetchMock.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(dataWithUnknownVersion), - }); - - render(); - - await waitFor(() => { - expect(screen.getByText('plot of the day')).toBeInTheDocument(); - }); - - // Should show "Matplotlib" without " unknown" appended - // The bottom bar text node should contain just "Matplotlib" followed by python version - const bottomText = screen.getByText(/Matplotlib/); - expect(bottomText.textContent).not.toContain('unknown'); - }); -}); diff --git a/app/src/sections/landing/PlotOfTheDay.tsx b/app/src/sections/landing/PlotOfTheDay.tsx deleted file mode 100644 index cb3e4f6444..0000000000 --- a/app/src/sections/landing/PlotOfTheDay.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -import { Link as RouterLink } from 'react-router-dom'; - -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import CloseIcon from '@mui/icons-material/Close'; -import Box from '@mui/material/Box'; -import IconButton from '@mui/material/IconButton'; -import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; - -import { GITHUB_URL } from 'src/constants'; -import { useAnalytics } from 'src/hooks'; -import { useTheme } from 'src/hooks/useLayoutContext'; -import { apiGet, endpoints } from 'src/lib/api'; -import { specPath } from 'src/routes/paths'; -import { colors, fontSize, semanticColors, typography } from 'src/theme'; -import { buildSrcSet, getFallbackSrc } from 'src/utils/responsiveImage'; -import { selectPreviewUrl } from 'src/utils/themedPreview'; - -interface PlotOfTheDayData { - spec_id: string; - spec_title: string; - description: string | null; - library_id: string; - library_name: string; - language: string; - quality_score: number; - preview_url_light?: string | null; - preview_url_dark?: string | null; - preview_url: string | null; - image_description: string | null; - library_version: string | null; - python_version: string | null; - language_version: string | null; - date: string; -} - -const mono = typography.fontFamily; - -export function PlotOfTheDay() { - const [data, setData] = useState(null); - 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(() => { - if (dismissed) return; - apiGet(endpoints.plotOfTheDay) - .then(setData) - .catch(() => {}) - .finally(() => setLoading(false)); - }, [dismissed]); - - 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; - - // Still loading — reserve space to prevent CLS - if (loading) { - return ; - } - - // Fetch failed or no data — collapse (post-initial-paint, negligible CLS) - if (!data) return null; - - return ( - - - {/* Top bar — full width terminal prompt */} - - - $ - - {(() => { - // Per-language file extension + runner command. Anyplot ships Python - // for nine libraries, R for ggplot2, Julia for makie, and JavaScript - // for chartjs/d3/echarts/highcharts/muix; the chip mimics what a user - // would actually type into a shell, so the runner label flips too. - // muix is the React (.tsx) exception within JavaScript. - const ext = - data.library_id === 'muix' - ? '.tsx' - : data.language === 'r' - ? '.R' - : data.language === 'julia' - ? '.jl' - : data.language === 'javascript' - ? '.js' - : '.py'; - const runner = - data.language === 'r' - ? 'Rscript' - : data.language === 'julia' - ? 'julia --project=.' - : data.language === 'javascript' - ? 'node' - : 'python'; - return ( - { - 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', - textDecoration: 'none', - '&:hover': { color: colors.primary }, - }} - > - {runner} plots/{data.spec_id}/{data.library_id} - {ext} - - ); - })()} - - - - - - {/* Middle — image left, info right */} - - {/* Image */} - - trackEvent('nav_click', { - source: 'potd_image', - target: 'spec_detail', - spec: data.spec_id, - library: data.library_id, - }) - } - sx={{ - display: 'block', - textDecoration: 'none', - flexShrink: 0, - width: { xs: '100%', sm: '50%' }, - '&:hover': { opacity: 0.95 }, - }} - > - {previewUrl && ( - - - - - - )} - - - {/* Info */} - - {/* Label */} - - - - plot of the day - - - - {/* Title */} - - trackEvent('nav_click', { - source: 'potd_title', - target: 'spec_detail', - spec: data.spec_id, - library: data.library_id, - }) - } - sx={{ - textDecoration: 'none', - color: 'var(--ink)', - '&:hover': { color: colors.primaryDark }, - }} - > - - {data.spec_title} - - - - {/* Description */} - {data.image_description && ( - - “{data.image_description.trim()}” - - )} - - - - {/* Bottom bar — terminal output style */} - - - >>> - - - plot.png saved - - - - │ - - - {data.library_name} - {data.library_version && data.library_version !== 'unknown' - ? ` ${data.library_version}` - : ''}{' '} - ·{' '} - {data.language === 'r' - ? 'R' - : data.language === 'julia' - ? 'Julia' - : data.language === 'javascript' - ? 'JavaScript' - : 'Python'}{' '} - {data.language_version || - data.python_version || - (data.language === 'r' - ? '4.4' - : data.language === 'julia' - ? '1.11' - : data.language === 'javascript' - ? '22' - : '3.13')} - - - - - ); -} diff --git a/app/src/sections/landing/ScienceNote.tsx b/app/src/sections/landing/ScienceNote.tsx deleted file mode 100644 index 515ba7cfdd..0000000000 --- a/app/src/sections/landing/ScienceNote.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { Link as RouterLink } from 'react-router-dom'; - -import Box from '@mui/material/Box'; - -import { paths } from 'src/routes/paths'; -import { PaletteStrip } from 'src/sections/landing/PaletteStrip'; -import { colors, typography } from 'src/theme'; - -export function ScienceNote() { - return ( - - - - § 03 · On the palette - - - - Colors that everyone can see. - - - - A palette unambiguous to colourblind and non-colourblind viewers alike, warm-tinted to - stay legible on screen and in print. - - - - — anyplot imprint, design rationale - - - - - - palette.explore() - - - - ); -} diff --git a/app/src/sections/landing/index.ts b/app/src/sections/landing/index.ts index 396b7c3aab..90e5fa6b7f 100644 --- a/app/src/sections/landing/index.ts +++ b/app/src/sections/landing/index.ts @@ -1,8 +1,5 @@ export * from 'src/sections/landing/HeroSection'; export * from 'src/sections/landing/LibrariesSection'; export * from 'src/sections/landing/NumbersStrip'; -export * from 'src/sections/landing/PaletteStrip'; -export * from 'src/sections/landing/PlotOfTheDay'; export * from 'src/sections/landing/PlotOfTheDayTerminal'; -export * from 'src/sections/landing/ScienceNote'; export * from 'src/sections/landing/TypewriterText'; diff --git a/app/src/sections/spec-detail/CodeShowcase.tsx b/app/src/sections/spec-detail/CodeShowcase.tsx deleted file mode 100644 index 7e391b61f2..0000000000 --- a/app/src/sections/spec-detail/CodeShowcase.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import Box from '@mui/material/Box'; - -import { SectionHeader } from 'src/components/SectionHeader'; -import { typography } from 'src/theme'; - -export function CodeShowcase() { - return ( - - - One import. - - } - /> - - - {/* Left: description */} - - - Same palette, -
- every library. -
- - every example in the catalogue uses the same imprint palette. switch libraries without - losing your color grammar — a gentoo penguin is - always blue, whether you draw it in matplotlib or plotly. - - - validated against deuteranopia, protanopia and tritanopia using the Machado et al. - (2009) simulation model. - -
- - {/* Right: code block — terminal showcase, intentionally dark in both themes - so the macOS-style window dots and drop shadow stay coherent. */} - - - - {'# pick any library. the palette travels with you.\n'} - - - import - - {' anyplot '} - - as - - {' ap\n\n'} - {'data = ap.'} - - load - - {'('} - - "penguins" - - {')\n\n'} - - {'# matplotlib\n'} - - {'ap.'} - - mpl - - {'.'} - - scatter - - {'(data, x='} - - "bill" - - {', y='} - - "flipper" - - {',\n hue='} - - "species" - - {')\n\n'} - - {'# plotly — same colors, interactive\n'} - - {'ap.'} - - plotly - - {'.'} - - scatter - - {'(data, x='} - - "bill" - - {', y='} - - "flipper" - - {',\n hue='} - - "species" - - {')'} - - -
-
- ); -} diff --git a/app/src/sections/spec-detail/index.ts b/app/src/sections/spec-detail/index.ts index 3b2f699ffa..0dbe83b1ca 100644 --- a/app/src/sections/spec-detail/index.ts +++ b/app/src/sections/spec-detail/index.ts @@ -1,4 +1,3 @@ -export * from 'src/sections/spec-detail/CodeShowcase'; export * from 'src/sections/spec-detail/LibraryPills'; export * from 'src/sections/spec-detail/RelatedSpecs'; export * from 'src/sections/spec-detail/SpecDetailView'; diff --git a/docs/reference/seo.md b/docs/reference/seo.md index 6347fd72f1..1ac0acc665 100644 --- a/docs/reference/seo.md +++ b/docs/reference/seo.md @@ -344,7 +344,7 @@ filtering is served via a `?language=` query param on the hub, and the hub's canonical tag omits the query — so the hub and its language-filtered variants all consolidate on the same canonical URL. Legacy links to `/{spec_id}/{language}` redirect to `/{spec_id}?language={language}` (SPA -client-side redirect via `app/src/router.tsx`; bots get a 301 from +client-side redirect via `app/src/routes/index.tsx`; bots get a 301 from `/seo-proxy/{spec_id}/{language}` to `/seo-proxy/{spec_id}`). The interactive view follows the same pattern: `?view=interactive` is a diff --git a/docs/reference/style-guide.md b/docs/reference/style-guide.md index 56f78542ca..16853476a1 100644 --- a/docs/reference/style-guide.md +++ b/docs/reference/style-guide.md @@ -446,7 +446,7 @@ It does **not** appear in: - Static (non-cursor, non-status) decorative dots or glyphs - **Default colour on in-prose links** (see below) -**In-prose link treatment.** Links inside body text default to `--ink-soft` with a 1px `--rule` underline (via `text-decoration`). On hover the colour flips to `--imprint-green` and the underline thickens to `currentColor`. Do **not** set `color: colors.primary` as the default on inline links — brand green stays a signal colour that only appears on interaction. The reusable sx object is exported from `app/src/theme/index.ts` as `proseLinkStyle`; import it everywhere a contextual link lives in prose (About, Legal, MCP, Palette, Stats). +**In-prose link treatment.** Links inside body text default to `--ink-soft` with a 1px `--rule` underline (via `text-decoration`). On hover the colour flips to `--imprint-green` and the underline thickens to `currentColor`. Do **not** set `color: colors.primary` as the default on inline links — brand green stays a signal colour that only appears on interaction. The reusable sx object is exported from `app/src/theme/tokens.ts` (re-exported via `src/theme`) as `proseLinkStyle`; import it everywhere a contextual link lives in prose (About, Legal, MCP, Palette, Stats). ```ts import { proseLinkStyle } from '../theme'; @@ -1193,7 +1193,7 @@ For CSS: The design system is implemented across: - **HTML reference (full mockup)**: `mockups/landing.html` — single-file reference with all sections, SVG plots, and animations -- **Theme tokens (frontend)**: `app/src/theme/index.ts` and `app/src/main.tsx` — MUI theme exports for colors, typography, spacing, headingStyle, subheadingStyle, textStyle, tableStyle, codeBlockStyle +- **Theme tokens (frontend)**: `app/src/theme/tokens.ts` (design tokens: colors, typography, spacing, headingStyle, subheadingStyle, textStyle, tableStyle, codeBlockStyle) and `app/src/theme/create-theme.ts` (MUI theme composition); both re-exported via `src/theme` - **Palette (Python library)**: `anyplot.palette` — see §9.5 **Reference CSS skeleton:**