diff --git a/agentic/docs/project-guide.md b/agentic/docs/project-guide.md index f6f165a605..d942583b90 100644 --- a/agentic/docs/project-guide.md +++ b/agentic/docs/project-guide.md @@ -108,21 +108,29 @@ uv run ruff check && uv run ruff format ```bash cd app yarn install -yarn dev # Development server -yarn tsc --noEmit # Type-check only (catches TS6133 unused vars etc.) +yarn dev # Development server (TS/ESLint errors show as browser overlay) +yarn lint # ESLint (0 errors required) +yarn fm:check # Prettier check (fm:fix to write, fix:all for both) +yarn type-check # tsc for app AND test files (tsconfig.test.json) +yarn test # Vitest yarn build # Production build (runs tsc + vite build) ``` -**IMPORTANT: Run `yarn tsc --noEmit` (or `yarn build`) before committing frontend changes.** -Vite's HMR dev server is permissive — it does NOT fail on unused variables, unused imports, -or other TS strict errors. Cloud Build runs `tsc && vite build` and will fail on any TS6133 -("declared but never read") errors. Catching these locally before `git push` saves a Cloud -Build round-trip. Common traps: +**Run the full gate before committing frontend changes:** +`yarn lint && yarn fm:check && yarn type-check && yarn test`. CI enforces the +same gates in the `test-frontend` job, and `yarn dev` surfaces TS/ESLint +errors live via vite-plugin-checker — but Vite HMR itself is permissive, so +don't rely on the dev server alone. Cloud Build runs `tsc && vite build` and +fails on any strict-TS error (e.g. TS6133 "declared but never read"). Common traps: - Removing a prop's usage from a component body but forgetting to remove it from `XxxProps` - Removing a feature (e.g. color accents) but leaving the import (`import { colors }`) - Changing a hook's shape and leaving a now-unused destructured name +Structure, conventions (src/ alias imports, `paths.*` URLs, `lib/api` client, +theme tokens), and how to add pages/sections/hooks: see +[`app/ARCHITECTURE.md`](../../app/ARCHITECTURE.md). + ## Architecture ### Plot-Centric Design diff --git a/app/ARCHITECTURE.md b/app/ARCHITECTURE.md new file mode 100644 index 0000000000..dc5d90edfa --- /dev/null +++ b/app/ARCHITECTURE.md @@ -0,0 +1,99 @@ +# Frontend Architecture + +React 19 + Vite + TypeScript (strict) + MUI 9, packaged with yarn. This is the +frontend-specific companion to [`agentic/docs/project-guide.md`](../agentic/docs/project-guide.md); +structure follows patterns popularized by mature MUI dashboards (thin pages, +feature sections, central route/theme/config modules). + +## Directory layout + +``` +src/ + main.tsx Bootstrap only: root render, global CSS, web vitals + app.tsx Provider composition (ThemeProvider → CssBaseline → AppRouter) + global-config.ts Typed CONFIG: app metadata, API base URLs (VITE_*), isDev + routes/ Router (index.tsx, lazy-loaded pages, error boundaries) + and paths.ts — the single source of truth for app URLs + layouts/ App shell: RootLayout, Layout (AppDataProvider), NavBar, + Footer, MastheadRule, BareLayout + pages/ One component per route; coordinate hooks + sections + sections/ Feature UI, grouped by surface: + landing/ Hero, NumbersStrip, LibrariesSection, PlotOfTheDay… + plots-gallery/ FilterBar/ (feature folder), ImagesGrid, ImageCard… + spec-detail/ SpecTabs/ (feature folder), SpecDetailView, SpecOverview… + libraries/ LibraryCard + components/ Shared primitives only (LoaderSpinner, SectionHeader, + ErrorBoundary, CodeHighlighter, ThemeToggle, …) + hooks/ Reusable state/data hooks (useFilterState, useCodeFetch, + useForceGraphSimulation, …) — barrel in index.ts + lib/ Third-party/client isolation: api.ts (apiGet/apiPost, + ApiError, endpoints registry, fetchWithAuth) + theme/ tokens.ts (design tokens), palette/typography/components + option modules, create-theme.ts; index.ts re-exports all + utils/ Pure helpers (filters, fuzzySearch, responsiveImage, …) + constants/ Domain constants (libraries, language maps); re-exports CONFIG + types/ Shared domain types (PlotImage, Implementation, …) + analytics/ reportWebVitals + styles/ tokens.css (CSS custom properties, dark mode), fonts.css +``` + +## Conventions + +- **Imports** are absolute from the `src/` alias (`import { paths } from 'src/routes/paths'`); + no relative imports between `src/` modules (the one exception: files reaching + outside `src/`, e.g. `global-config.ts` importing `../package.json`). + ESLint (perfectionist) enforces sorted, grouped imports. +- **Naming**: `PascalCase.tsx` components, `useXxx.ts` hooks, lowercase modules elsewhere. +- **URLs** never appear as string literals in components — use `paths.*` from + `src/routes/paths` (static routes, `paths.plotsFiltered(param, value)`, + `paths.spec(specId, language, library)`). +- **API access** to the anyplot backend goes through `src/lib/api` + (`apiGet`/`apiPost` + `endpoints`); raw `fetch()` against our backend lives + only inside that module. External third-party APIs (e.g. the GitHub releases + call in `useLatestRelease`) may fetch directly. Callers own caching/abort/dedup. +- **Config**: read `CONFIG` from `src/global-config` instead of `import.meta.env`. +- **Theme**: design tokens (colors, font stacks, style constants) come from + `src/theme` (tokens); MUI theme composition lives in `theme/create-theme.ts`. + Dark mode works via CSS custom properties (`styles/tokens.css`, `[data-theme]`). +- **Feature folders**: when a section component outgrows one file (FilterBar, + SpecTabs), it becomes a folder with `index.tsx` as orchestrator + focused + sub-components, keeping the import path stable. + +## Data flow + +Pages coordinate; hooks fetch and hold state; sections/components render. +`RootLayout` (via `Layout`'s `AppDataProvider`) loads app-wide data once +(specs, libraries, languages, stats); route components are lazy-loaded in +`routes/index.tsx` with `RouteErrorBoundary` keeping the shell alive on errors. + +## Testing + +Vitest + Testing Library, jsdom, globals enabled. Tests are colocated +(`X.test.tsx` next to `X.tsx`) and render through `src/test-utils`, which wraps +the real app theme and a `MemoryRouter`. Mock `globalThis.fetch` (not +`lib/api`) so URL construction stays covered. Coverage scope is configured in +`vitest.config.ts`. + +## Quality gates + +```bash +cd app +yarn lint && yarn fm:check && yarn type-check && yarn test && yarn build +``` + +CI (`.github/workflows/ci-tests.yml`, job `test-frontend`) runs lint, format +check, type-check, and tests; `yarn build` itself runs during the Cloud Build +Docker build on merge to main — that's why the local gate includes it. +`yarn type-check` covers app and test files (`tsconfig.test.json`). During +development, `vite-plugin-checker` surfaces TS/ESLint errors in the browser. + +## Adding things + +- **Page**: create `pages/NewPage.tsx`, register it lazily in + `routes/index.tsx`, add its path to `routes/paths.ts` (and to + `RESERVED_TOP_LEVEL` — spec ids share the URL root). +- **Section**: create it under `sections//`, export from the folder + barrel, render it from a page. +- **Hook**: create `hooks/useNewThing.ts`, export from `hooks/index.ts`, + colocate `useNewThing.test.ts`. +- **Endpoint**: add a builder to `endpoints` in `lib/api.ts` (alphabetical). diff --git a/app/src/layouts/MastheadRule.tsx b/app/src/layouts/MastheadRule.tsx index 7f1fca42df..567028f5a4 100644 --- a/app/src/layouts/MastheadRule.tsx +++ b/app/src/layouts/MastheadRule.tsx @@ -7,7 +7,7 @@ import Box from '@mui/material/Box'; import { ThemeToggle } from 'src/components/ThemeToggle'; import { LANG_EXT, LIB_ABBREV } from 'src/constants'; import { useAnalytics, useLatestRelease, useTheme } from 'src/hooks'; -import { paths, RESERVED_TOP_LEVEL } from 'src/routes/paths'; +import { paths, RESERVED_TOP_LEVEL, specPath } from 'src/routes/paths'; import { colors, typography } from 'src/theme'; // Symmetric block-comment delimiters used when no language context is in the URL. @@ -85,13 +85,13 @@ function pathSegments(pathname: string): Segment[] { const [specId, language, library] = parts; const segs: Segment[] = []; if (language) { - segs.push({ label: specId, to: `/${specId}` }); + segs.push({ label: specId, to: specPath(specId) }); } else { segs.push({ label: specId }); } if (language) { if (library) { - segs.push({ label: language, to: `/${specId}/${language}`, short: LANG_EXT[language] }); + segs.push({ label: language, to: specPath(specId, language), short: LANG_EXT[language] }); segs.push({ label: library, short: LIB_ABBREV[library] }); } else { segs.push({ label: language, short: LANG_EXT[language] }); diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx index fcef4f67f6..b665b0bad4 100644 --- a/app/src/pages/SpecPage.tsx +++ b/app/src/pages/SpecPage.tsx @@ -309,7 +309,7 @@ export function SpecPage() { if (mode === 'hub') { trackPageview(); } else if (mode === 'detail' && selectedLibrary) { - trackPageview(`/${specId}/${urlLanguage}/${selectedLibrary}`); + trackPageview(specPath(specId, urlLanguage, selectedLibrary)); } }, [specData, mode, specId, urlLanguage, selectedLibrary, languageFilter, trackPageview]); diff --git a/app/src/routes/index.tsx b/app/src/routes/index.tsx index b0e33c37e3..75fad33a6a 100644 --- a/app/src/routes/index.tsx +++ b/app/src/routes/index.tsx @@ -9,6 +9,7 @@ import { RouteErrorBoundary } from 'src/components/RouteErrorBoundary'; import { AppDataProvider } from 'src/layouts/Layout'; import { RootLayout } from 'src/layouts/RootLayout'; import { NotFoundPage } from 'src/pages/NotFoundPage'; +import { specPath } from 'src/routes/paths'; const LazyFallback = () => ( @@ -27,7 +28,7 @@ function SpecLanguageRedirect() { if (!specId || !language) return ; return ( );