Files
job-search/web/CODE_CONVENTIONS.md
T
bwilliams 1832ea7437 Added the frontend in web/, following its code conventions, with linting and generated backend types
web/CODE_CONVENTIONS.md covers the Vite, SolidJS, Tailwind and TypeScript
frontend: tooling, files, imports, exports, naming, TypeScript, formatting,
Solid's reactivity, styling, the backend's types, API calls, errors,
security, formatting values, comments, accessibility, storage,
dependencies and tests. npm run check (strict tsc -b, ESLint and Prettier)
passes, and npm run build builds it.

ESLint (typescript-eslint with type information, eslint-plugin-solid,
simple-import-sort, eslint-comments, and rules for the naming, type import,
export type and promise conventions) and Prettier (with the Tailwind class
plugin) check what they can. Tool versions are pinned exactly. Imports
between folders use @/, which works in Vite as well as TypeScript.

The backend's types are generated from its OpenAPI description into
src/types/api.ts by npm run generate:api, which turns swag's x-nullable
into null types first; src/types/Opportunities.ts names them for the app.

The app works against the backend's API: it fetches every page of
/api/opportunities with each opportunity's marks, the classifications to
filter by from /api/classifications, sets and clears marks (hidden among
them) with the CSRF token, and shows the OCDS tender statuses. Its lists'
requests are aborted when reloaded or no longer needed. The dev server
proxies /api and /ws to job-search serve, by default at localhost:9393.
web/go.mod keeps web/ out of the Go module, as an npm package in
node_modules ships Go code.

The page stays up to date over the backend's WebSocket (src/api/events.ts,
with hand-written event types in src/types/Events.ts), reconnecting with a
growing delay. ClassificationsUpdated changes the filter's classifications
in place and OpportunityMarksUpdated sets an opportunity's marks (unless
this page is still saving its own change). OpportunitiesUpdated catches the
list up: it asks for the opportunities changed after the last change batch
it has seen (changed_after) and merges them in, reloading the whole list
only when the backend doesn't know the batch. A reconnect catches up the
same way and reloads the classifications.
2026-10-16 16:01:38 +01:00

20 KiB

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); the rest are applied by hand and checked in review.

Tooling

npm run check          # typecheck, lint and format:check; 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 generate:api   # regenerate src/types/api.ts from the backend's OpenAPI description
npm run build          # typecheck, then vite build
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. 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); --fix sorts them
no-restricted-imports Imports from another folder use @/, not ../ (Imports)
@typescript-eslint/consistent-type-imports Types are imported as types, never as values (Imports)
@typescript-eslint/naming-convention camelCase, PascalCase and UPPER_SNAKE_CASE where 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-eslint/explicit-module-boundary-types Exported functions have their parameter and return types written out (TypeScript)
@typescript-eslint/no-misused-promises, no-floating-promises Promises are awaited or handled, including in event handlers (SolidJS)
solid/no-destructure, solid/reactivity Props aren't destructured, and reactive values are read where they can update (SolidJS)
solid/prefer-for Lists use <For> (SolidJS)
solid/no-innerhtml No innerHTML from data (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 --:

// 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:

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) 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)
  • 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).

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.

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. 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):

    export const OPPORTUNITY_MARKS = ['hidden', 'skipped'] as const;
    export type OpportunityMark = (typeof OPPORTUNITY_MARKS)[number];
    
  • Object shapes are interfaces; unions and derived types are types.

  • 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 <For>, conditions <Show> and <Switch>/<Match>, 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. <Suspense> shows a placeholder while resources load, and an <ErrorBoundary> 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 Errors.
  • 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.

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 <button> (with type="button"), <input>, <label> or link, so it works with a keyboard.
  • Controls without visible text have an aria-label; one whose text is ambiguous in a table row includes what it belongs to ("Marks for