# Frontend code conventions These are the conventions for the frontend in `web/`: a Vite, SolidJS, Tailwind CSS (v4) and TypeScript app. TypeScript, ESLint and Prettier check what they can (see [Tooling](#tooling)); the rest are applied by hand and checked in review. ## Tooling ```bash npm run check # typecheck, lint, format:check and test; run before committing npm run typecheck # tsc -b: both tsconfigs (plain tsc would check neither) npm run lint # eslint . npm run format:check # prettier --check . npm run format # prettier --write .: formats every file npm run test # vitest run: every test, once npm run test:watch # vitest: reruns the tests affected by each change npm run generate:api # regenerate src/types/api.ts from the backend's OpenAPI description npm run build # typecheck, then vite build into dist/ npm run sync # copy dist/ to ../internal/frontend/dist/, which the backend embeds npm run dev # dev server; /api and /ws go to the backend (API_PROXY_TARGET, default localhost:9393) ``` - **TypeScript** runs in `strict` mode, with `noUncheckedIndexedAccess` (an indexed value may be `undefined` until checked). `tsconfig.app.json` also sets `verbatimModuleSyntax`, `erasableSyntaxOnly`, `noUnusedLocals`, `noUnusedParameters` and `noFallthroughCasesInSwitch`: in particular, no TypeScript-only runtime syntax such as constructor parameter properties or `enum`, and types are imported with `import type`. `tsconfig.node.json` checks `vite.config.ts` and `scripts/`. - **ESLint** (`eslint.config.js`): ESLint's and typescript-eslint's recommended rules, with type information, so it catches unsafe uses of `any` and promises that are dropped; eslint-plugin-solid's rules; eslint-comments' rules; simple-import-sort; and the rules below. - **Prettier** (`.prettierrc.json`) formats everything: see [Formatting](#formatting). `eslint-config-prettier` turns off the ESLint rules that would disagree with it. - Generated code (`src/types/api.ts`) isn't linted or formatted, and is never edited by hand. ### Linters that check these conventions | Rule | Checks | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `simple-import-sort/imports`, `/exports` | Imports in two groups, each sorted ([Imports](#imports)); `--fix` sorts them | | `no-restricted-imports` | Imports from another folder use `@/`, not `../` ([Imports](#imports)) | | `@typescript-eslint/consistent-type-imports` | Types are imported as types, never as values ([Imports](#imports)) | | `@typescript-eslint/naming-convention` | camelCase, PascalCase and UPPER_SNAKE_CASE where [Naming](#naming) says; backend keys are exempt | | `@typescript-eslint/no-explicit-any`, `no-unsafe-*` | No `any`, and no untyped values used as if typed ([TypeScript](#typescript)) | | `@typescript-eslint/explicit-module-boundary-types` | Exported functions have their parameter and return types written out ([TypeScript](#typescript)) | | `@typescript-eslint/no-misused-promises`, `no-floating-promises` | Promises are awaited or handled, including in event handlers ([SolidJS](#solidjs)) | | `solid/no-destructure`, `solid/reactivity` | Props aren't destructured, and reactive values are read where they can update ([SolidJS](#solidjs)) | | `solid/prefer-for` | Lists use `` ([SolidJS](#solidjs)) | | `solid/no-innerhtml` | No `innerHTML` from data ([Security](#security)) | | `@eslint-community/eslint-comments/require-description` | Every `eslint-disable` comment says why | | `eqeqeq` | `===` and `!==`, never `==` | ESLint's `no-unassigned-vars` is off for `.tsx` files: Solid assigns refs itself (`ref={button}`), which ESLint can't see. Any other rule is turned off only for a line, naming the rule and saying why after `--`: ```ts // eslint-disable-next-line rule-name -- why the rule is wrong here ``` Most `no-unsafe-*` reports don't need one: give the `any` value a type of `unknown` and narrow it, as for `JSON.parse`: ```ts const parsed: unknown = JSON.parse(release.data); if (isRelease(parsed)) { ... } ``` ## Files and folders | Folder | Holds | | ----------------- | -------------------------------------------------------------------------------------------------- | | `src/api/` | The only code that talks to the backend: HTTP (`client.ts`) and its WebSocket events (`events.ts`) | | `src/types/` | The backend's types (generated, see [Backend types](#backend-types)) and the app's own | | `src/primitives/` | Solid primitives: state and loading, no markup (`createX.ts`) | | `src/components/` | Components, one exported component per file | | `src/lib/` | Plain functions: no Solid, no DOM where avoidable | | `scripts/` | Development scripts run by npm, in TypeScript (`generateApiTypes.ts`, `syncDist.ts`) | - Components' files are PascalCase, named after the component (`MarkMenu.tsx`). Other files are usually camelCase (`format.ts`); the type files' PascalCase names (`Opportunities.ts`) are fine too. - A file is named after what it exports, exactly: `OpportunityTable.tsx` exports `OpportunityTable`. An import must name the file as it is spelt on disk. - The top of `src/` holds only the entry point (`index.tsx`), the app's layout (`App.tsx`), the stylesheet (`index.css`) and Vite's type declarations (`vite-env.d.ts`). - `components/styles.ts` holds the Tailwind class strings shared by several components (see [Styling](#styling)). ## Imports Two groups, separated by a blank line: packages (`solid-js`, `node:fs`, ...) first, then the app's own modules, those from other folders (`@/`) before those from the same folder (`./`). Each group is sorted by module; a type import comes before the value import from the same module. simple-import-sort checks this, and `npm run lint -- --fix` sorts them. ```ts import { createSignal, onCleanup } from 'solid-js'; import { api } from '@/api/client'; import type { OpportunityMark } from '@/types/Annotations'; import { normaliseMarks } from '@/types/Annotations'; import { MARK_BG } from './styles'; ``` - An import from another folder of `src` starts with `@/`, which is `src/` (set in both `tsconfig.app.json` and `vite.config.ts`): `@/api/client`, not `../api/client`. One from the same folder starts with `./`. So an import reads the same from every file, and doesn't change when the importing file moves. `../` isn't used (`no-restricted-imports` reports it). - Types are imported with `import type`, in their own statement rather than inline (`import { api, type Api }`). `consistent-type-imports` checks that types are imported as types; the separate statement is kept by hand. ## Exports - Exports are named: `export function MarkMenu`, not `export default function`. A named export is imported under one name everywhere, so renaming and searching are reliable. - A default export only where a tool requires one. ## Naming Names are descriptive: `opportunity`, `classification`, `controller`. Short names are fine where the meaning is obvious from a few lines around them: a one-line callback's parameter (`(mark) =>` or `(m) =>`), an event (`e` or `event`), a caught error (`err`), a plain index (`i`). | Kind | Case | Example | | ------------------------------------------------------------------ | ------------------------------- | --------------------------------------- | | Variables, functions, props | camelCase | `toggleMark`, `opportunityTitle` | | Components, types, interfaces | PascalCase | `MarkMenu`, `OpportunityAnnotation` | | Module-level constants holding fixed data (numbers, lookups, keys) | UPPER_SNAKE_CASE | `MENU_WIDTH`, `MARK_BG`, `STORAGE_KEYS` | | Tailwind class strings | camelCase | `buttonQuiet`, `fieldLabel` | | Primitives | `create` + PascalCase | `createOpportunityAnnotations` | | Event props | `on` + PascalCase | `onToggle`, `onChange` | | Booleans | `is`, `has`, `can`, `should`... | `isSaving`, `hasDocuments` | - A word keeps one case throughout a name: `opportunityTitle`, not `OpportunityTitle` for a prop, and `OPPORTUNITY_MARKS`, not `Opportunity_MARKS`. A local variable is never PascalCase (`opportunities`, not `Opportunities`). - Initialisms keep one case: `apiBase`, `API_BASE`, `uuid`, `OCDSRelease`. - Backend field names are kept as the backend sends them (`buyer_name`, `has_documents`): see [Backend types](#backend-types). A boolean's prefix applies to the app's own names. ## TypeScript - No `any`. A value of unknown shape is `unknown`, narrowed before use (`err instanceof Error`, a type guard such as `isOpportunityMark`). - A type assertion (`as T`) only where TypeScript can't know a fact the code does, such as the shape of `fetch` JSON; never to silence an error. Non-null assertions (`!`) only for elements that must exist, such as the root element, and Solid refs assigned before use (`let button!: HTMLButtonElement`). - With `noUncheckedIndexedAccess`, a value read by index or key (`marks[0]`, `byUuid[uuid]`) may be `undefined`: check it, or give a default with `??`. - Fixed lists are `as const` arrays, with a union type derived from them, rather than an `enum` (which `erasableSyntaxOnly` forbids): ```ts export const OPPORTUNITY_MARKS = ['hidden', 'skipped'] as const; export type OpportunityMark = (typeof OPPORTUNITY_MARKS)[number]; ``` - Object shapes are `interface`s; unions and derived types are `type`s. - Exported functions have their parameter and return types written out (`explicit-module-boundary-types`); others where the type isn't obvious. ## Formatting Prettier formats every file (`npm run format`), so formatting isn't reviewed. Its settings: two-space indentation, semicolons, single quotes in TypeScript and double quotes in JSX attributes, trailing commas, and a 100-character line. prettier-plugin-tailwindcss sorts the classes in `class` strings into Tailwind's order, so the same classes always read the same way. Blank lines are still applied by hand: one between a file's top-level declarations, and between the steps of a long function. ## SolidJS Solid components run once; only reactive reads (signals, store fields, `props.x`) update the page. The conventions keep reactivity working: - **Props are never destructured**, in the parameter list or the body: `props.marks`, not `({ marks })`. Destructuring reads the value once and stops it updating. Derived values are functions: `const marks = () => annotation().marks`. - Default prop values come from `mergeProps`, and props passed on to a child are separated with `splitProps`: both keep the props reactive, where destructuring wouldn't. - A component's props are an `interface Props` in its file. Callbacks are `on…` props; components don't reach into shared state they weren't given. - Lists use ``, conditions `` and ``/``, not `.map()` or `&&` in JSX. - State lives in primitives (`src/primitives/`): a `createX()` function returning an interface of accessors and actions, documented on the interface. Components call primitives; primitives don't render. - Data is loaded with `createResource`, which tracks loading and errors and refetches when its source changes. `` shows a placeholder while resources load, and an `` around a part of the page shows an error in it instead of breaking the whole page. - Work started in a primitive or component is cleaned up with `onCleanup`: listeners removed, timers cleared, and fetches aborted through an `AbortController`. An aborted request isn't reported as an error. - A change may be applied optimistically, before the request, where instant feedback matters and the request almost always succeeds (as marks do in `createOpportunityAnnotations`). It is then undone if the request fails, and the failure shown. - An event handler doesn't return a promise: Solid ignores it, so a rejection would go unnoticed. A handler calls an async function that handles its own errors, and marks the call with `void`: `onClick={() => void annotations.toggleMark(uuid, mark)}`. ## Styling - Styles are Tailwind utility classes in `class`. Conditional classes use `classList`. No CSS files beyond `src/index.css`. - Class names are always written out in full in the source: Tailwind only generates classes it can see, so never build them at runtime (`` `bg-mark-${mark}` ``). A lookup from a value to its classes is a record with every class written out, as `MARK_BG` in `styles.ts` is. - Colours and fonts are theme tokens in the `@theme` block of `src/index.css` (`bg-surface`, `text-muted`), not raw values. Dark mode swaps the tokens under `.dark`, so components never use `dark:` variants. - Class strings used by more than one component go in `components/styles.ts`; ones used by one component are constants in its file. ## Backend types The backend's types are generated from its OpenAPI 3.1 description (`/api/openapi.json`) into `src/types/api.ts`, by `npm run generate:api` (`scripts/generateApiTypes.ts`). They're never written by hand (with one exception, the WebSocket events, below), so they can't drift from the backend. - `src/types/Opportunities.ts` gives the generated types the names the app uses (`Opportunity`, `OpportunityPage`, `Problem`); code imports them from there, not from `api.ts`. - After a change to the backend's types or handlers, regenerate: run `go generate ./...` in the repository, then `npm run generate:api`, and commit `api.ts` with the change. With the backend running, `npm run generate:api -- http://localhost:9393/api/openapi.json` reads the description from it instead. - Keys are the Go structs' `json` tags, snake_case included (`latest_release_date`). - A field the backend can send as `null` (a Go pointer, or a slice that can be nil) is typed `| null`, from the description's `x-nullable`, so TypeScript makes the code handle it: a list is read with `?? []` (`opportunity.classifications ?? []`). - Times are RFC 3339 strings (`DateTimeString`), converted to `Date` only for display. - The one exception is the WebSocket events (`src/types/Events.ts`): the OpenAPI description only covers HTTP routes, so the events' envelopes are written by hand, to match the backend's events, and use the generated types for their contents. Messages are parsed as `unknown` and narrowed (`src/api/events.ts`); unknown events are ignored. ## API calls - All requests go through `src/api/client.ts`: one function per operation on the exported `api` object, and one `request` helper that builds the URL from `VITE_API_BASE_URL` (default `/api`), sets the headers and turns a failed response into an `ApiError`. - Every function that loads data takes an optional `AbortSignal`. - Path segments taken from data are encoded with `encodeURIComponent`. - Unsafe requests (PUT, DELETE, POST) send the CSRF token the backend issues at `GET /api/csrf`, in the header it names; the client fetches and keeps it, so callers don't handle it. ## Errors - Errors are caught as `unknown` and turned into text with one helper (`errorText`), never by assuming they are `Error`s. - A message shown to the user says what didn't happen, in plain words, and then the cause: `Your marks didn't load: …`. Plain or typographic quotes and apostrophes are both fine. - Errors are shown where the user is looking, with `role="alert"`, and can be retried or dismissed; they're never only logged to the console. - An action that failed (a mark that wasn't saved) is shown as a toast in the bottom right corner (`createToasts`, `ToastList`), which goes after 15 seconds or when it's dismissed. A load that failed is shown in place of what didn't load, with a way to retry, and as a toast too. ## Security - Data from the API is shown as text, never as HTML: no `innerHTML` with it (`solid/no-innerhtml` reports one). Releases and descriptions come from outside sources, so HTML in them could run scripts. - Only settings that are safe to publish go in `VITE_` variables: Vite builds them into the JavaScript every visitor downloads. Nothing secret is ever in the frontend. - `.env` holds local settings and isn't committed; `.env.example` lists every variable, with a comment on what it does. ## Formatting values - Dates, money and numbers are formatted for display by the functions in `lib/format.ts`, with `Intl` and a fixed locale (`en-GB`) and currency (`GBP`), so they read the same on every machine. Components don't call `toLocaleString` or format by hand. - A missing or zero value (a null date, the zero time) is shown as a dash or left empty, not as a misleading default such as `01/01/0001`. ## Comments - Exported functions, components, primitives, interfaces and their non-obvious fields have a `/** … */` comment saying what they are or do. - `//` comments explain why code is the way it is (`// Tailwind only generates classes it can see …`), not what the next line does. - JSX comments use `{/* … */}`. ### Spelling Comments, documentation and user-facing text use British English ("normalise", "colour", "behaviour"). Names that must match something external keep its spelling (`Authorization`, CSS `color`). ## Accessibility - Every interactive element is a real `