`npm run sync` in web/ copies the build output (web/dist, from npm run build) to internal/frontend/dist, which the binary embeds. Go can't embed from outside the package's directory (web/ is also outside the Go module), hence the copy. Only dist/.gitkeep is committed, so a fresh clone still compiles, and a binary built without syncing first serves no frontend. job-search serve serves server.frontend.dir if it is set, or else the embedded build, or warns that there is no frontend. scripts/build.sh builds and syncs the frontend before building, running npm ci first for a release or when web/node_modules is missing.
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 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
strictmode, withnoUncheckedIndexedAccess(an indexed value may beundefineduntil checked).tsconfig.app.jsonalso setsverbatimModuleSyntax,erasableSyntaxOnly,noUnusedLocals,noUnusedParametersandnoFallthroughCasesInSwitch: in particular, no TypeScript-only runtime syntax such as constructor parameter properties orenum, and types are imported withimport type.tsconfig.node.jsonchecksvite.config.tsandscripts/. - ESLint (
eslint.config.js): ESLint's and typescript-eslint's recommended rules, with type information, so it catches unsafe uses ofanyand 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-prettierturns 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, 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.tsxexportsOpportunityTable. 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.tsholds 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
srcstarts with@/, which issrc/(set in bothtsconfig.app.jsonandvite.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-importsreports it). - Types are imported with
import type, in their own statement rather than inline (import { api, type Api }).consistent-type-importschecks that types are imported as types; the separate statement is kept by hand.
Exports
- Exports are named:
export function MarkMenu, notexport 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, notOpportunityTitlefor a prop, andOPPORTUNITY_MARKS, notOpportunity_MARKS. A local variable is never PascalCase (opportunities, notOpportunities). - 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 isunknown, narrowed before use (err instanceof Error, a type guard such asisOpportunityMark). -
A type assertion (
as T) only where TypeScript can't know a fact the code does, such as the shape offetchJSON; 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 beundefined: check it, or give a default with??. -
Fixed lists are
as constarrays, with a union type derived from them, rather than anenum(whicherasableSyntaxOnlyforbids):export const OPPORTUNITY_MARKS = ['hidden', 'skipped'] as const; export type OpportunityMark = (typeof OPPORTUNITY_MARKS)[number]; -
Object shapes are
interfaces; unions and derived types aretypes. -
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 withsplitProps: both keep the props reactive, where destructuring wouldn't. - A component's props are an
interface Propsin its file. Callbacks areon…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/): acreateX()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 anAbortController. 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 useclassList. No CSS files beyondsrc/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, asMARK_BGinstyles.tsis. - Colours and fonts are theme tokens in the
@themeblock ofsrc/index.css(bg-surface,text-muted), not raw values. Dark mode swaps the tokens under.dark, so components never usedark: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.tsgives the generated types the names the app uses (Opportunity,OpportunityPage,Problem); code imports them from there, not fromapi.ts.- After a change to the backend's types or handlers, regenerate: run
go generate ./...in the repository, thennpm run generate:api, and commitapi.tswith the change. With the backend running,npm run generate:api -- http://localhost:9393/api/openapi.jsonreads the description from it instead. - Keys are the Go structs'
jsontags, 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'sx-nullable, so TypeScript makes the code handle it: a list is read with?? [](opportunity.classifications ?? []). - Times are RFC 3339 strings (
DateTimeString), converted toDateonly 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 asunknownand 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 exportedapiobject, and onerequesthelper that builds the URL fromVITE_API_BASE_URL(default/api), sets the headers and turns a failed response into anApiError. - 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
unknownand turned into text with one helper (errorText), never by assuming they areErrors. - 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
innerHTMLwith it (solid/no-innerhtmlreports 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. .envholds local settings and isn't committed;.env.examplelists 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, withIntland a fixed locale (en-GB) and currency (GBP), so they read the same on every machine. Components don't calltoLocaleStringor 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>(withtype="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