Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions agentic/docs/project-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,21 +108,29 @@ uv run ruff check <files> && uv run ruff format <files>
```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
Expand Down
99 changes: 99 additions & 0 deletions app/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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/<surface>/`, 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).
6 changes: 3 additions & 3 deletions app/src/layouts/MastheadRule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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] });
Expand Down
2 changes: 1 addition & 1 deletion app/src/pages/SpecPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
3 changes: 2 additions & 1 deletion app/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
Expand All @@ -27,7 +28,7 @@ function SpecLanguageRedirect() {
if (!specId || !language) return <NotFoundPage />;
return (
<Navigate
to={{ pathname: `/${specId}`, search: `?language=${encodeURIComponent(language)}` }}
to={{ pathname: specPath(specId), search: `?language=${encodeURIComponent(language)}` }}
replace
/>
);
Expand Down
Loading