UI Quality Tooling
Objective
Standardize formatting and linting for the React + TypeScript UI using the OXC toolchain.
The goal is:
- fast feedback
- minimal configuration
- no formatter/linter conflicts
- predictable behavior across contributors and CI
This setup intentionally mirrors the philosophy of modern single-tool stacks (similar to Ruff on the backend).
Target path: apps/ui
Tooling Model (Important)
This setup intentionally splits responsibility:
Formatting
Oxfmt Handles all formatting concerns:
- whitespace
- quotes
- wrapping
- commas
There are no stylistic lint rules.
Linting
Oxlint Focuses on:
- correctness
- suspicious patterns
- performance pitfalls
Oxlint is not a plugin-based linter like ESLint. It does not attempt to replicate framework-specific rule ecosystems.
In addition to Oxlint, the enforced UI gate also includes:
- stylelint via
npm run lint:css - eslint-plugin-tailwindcss via
npm run lint:tailwind
Type Safety
- TypeScript (
tsc --noEmit) Enforces type correctness.
Oxlint does not replace TypeScript type checking.
What This Toolchain Does Not Do
This is intentional and by design:
- No React-hooks exhaustive dependency enforcement
- No JSX a11y plugin rules
- No broad framework plugin ecosystem beyond focused checks (for example Tailwind class linting)
- No arbitrary plugin ecosystem
These concerns are handled via:
- TypeScript
- code review
- shared patterns
- tests
1) Install dependencies
Using npm (works reliably on Windows, macOS, Linux):
npm --prefix apps/ui i -D oxlint @oxc/oxfmt typescriptRecommendation
- Pin exact versions of
oxlintand@oxc/oxfmt - Do not use caret (
^) ranges for these tools - The toolchain evolves quickly
2) Formatter configuration
Create apps/ui/.oxfmtrc.json:
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxfmt/schema.json",
"printWidth": 100,
"semi": true,
"singleQuote": true,
"trailingComma": "all"
}Keep formatting rules minimal to reduce churn.
Generated modules under src/generated/ are emitted byte-exact by scripts/generate-docs.py and verified by task docs:verify-sync, so they are excluded from formatting via apps/ui/.prettierignore (oxfmt reads it by default) - formatters must never rewrite verified generated output.
3) Linter configuration
Create apps/ui/.oxlintrc.json:
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/crates/oxlint/schema.json",
"rules": {
"correctness": "warn",
"suspicious": "warn",
"perf": "warn"
}
}Category rules (correctness, suspicious, perf) run as warnings; individual rules are promoted to error where the codebase satisfies them (see no-restricted-imports in .oxlintrc.json).
4) Ignore files
OXC tools (oxfmt and oxlint) ignore node_modules, dist, build, and coverage by default, so those need no configuration.
The two tools take additional exclusions from different files - there is no .oxfmtignore or .oxlintignore:
| Tool | Exclusion mechanism | Current entries |
|---|---|---|
oxfmt | apps/ui/.prettierignore (and .gitignore), read by default - confirmed by npx oxfmt --help under "Ignore Options" | src/generated/, e2e/fixtures/step-handler-types.json |
oxlint | the ignorePatterns array in apps/ui/.oxlintrc.json | see that file |
Both current oxfmt entries are generated artifacts whose bytes a generator owns and a gate verifies (task docs:verify-sync, task docs:verify-fixtures). That is the bar for adding an entry: a formatter and a byte-exact generator cannot both own one file. Do not exclude hand-written source to avoid fixing it.
5) UI package.json scripts
Update apps/ui/package.json:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"format": "oxfmt .",
"format:check": "oxfmt . --check",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"lint:css": "stylelint src/**/*.css",
"lint:css:fix": "stylelint src/**/*.css --fix",
"lint:tailwind": "eslint --config eslint.tailwind.config.mjs 'src/**/*.{tsx,jsx}'",
"typecheck": "tsc --noEmit",
"check:api-headers": "node scripts/check-api-headers.mjs",
"generate:types": "node scripts/generate-types.mjs",
"test:e2e": "playwright test",
"test:e2e:smoke": "playwright test -g @smoke --project=chromium",
"test:e2e:coverage": "COVERAGE=true playwright test",
"coverage:report": "node scripts/generate-v8-coverage.mjs --html",
"coverage:summary": "node scripts/generate-v8-coverage.mjs --summary",
"preview": "vite preview"
}
}Notes on format:check
oxfmt --checkis supportedIf a future version regresses, temporarily replace with:
- run
format - then
git diff --exit-codein CI
- run
Notes on generate:types
Canonical local workflow: use
task api:regenerateinstead of runningnpm run generate:typesdirectly. The Taskfile task also exports the OpenAPI spec and lints it with Spectral before regenerating UI TypeScript types.
6) Single formatter/linter stack
The UI has a single formatter/linter stack (Oxfmt + Oxlint). The only ESLint artifact is eslint.tailwind.config.mjs, required by npm run lint:tailwind, task ui:lint:tailwind, and CI; .prettierignore remains because oxfmt reads it for exclusions.
7) Pre-commit integration (repo root)
The canonical pre-commit hooks are defined in .pre-commit-config.yaml at the repo root — treat that file as the source of truth.
The current UI hooks use Taskfile tasks as entry points and cover:
- repo: local
hooks:
- id: ui-format-check
name: UI format check (oxfmt)
entry: task ui:format:check
language: system
pass_filenames: false
files: ^apps/ui/
- id: ui-lint
name: UI lint (oxlint)
entry: task ui:lint
language: system
pass_filenames: false
files: ^apps/ui/
- id: ui-lint-css
name: UI CSS lint (stylelint)
entry: task ui:lint:css
language: system
pass_filenames: false
files: ^apps/ui/src/.*\.css$
- id: ui-lint-tailwind
name: UI Tailwind lint (eslint-plugin-tailwindcss)
entry: task ui:lint:tailwind
language: system
pass_filenames: false
files: ^apps/ui/src/.*\.(tsx|jsx)$
- id: ui-typecheck
name: UI typecheck (tsc)
entry: task ui:typecheck
language: system
pass_filenames: false
files: ^apps/ui/
stages: [pre-push]
- id: ui-api-headers
name: UI API auth headers check
entry: task ui:check:api-headers
language: system
pass_filenames: false
files: ^apps/ui/src/api/client\.ts$Run task precommit to execute all default-stage (pre-commit) hooks locally. To also run hooks configured for the pre-push stage (for example ui-typecheck), run pre-commit run --all-files --hook-stage pre-push manually.
8) CI enforcement
CI must gate on these checks:
# All checks in task ui:all (format, lint, CSS, Tailwind, typecheck, api-headers):
task ui:all
# Additional CI gates — also required before pushing:
task api:verify-types-sync # verifies generated TS types are up-to-date
npm --prefix apps/ui run build
task ui:audit # npm audit --audit-level=highRun task ui:verify for lint/typecheck/build/types-sync parity with CI, plus task ui:audit for npm audit parity.
E2E dev server and workers
playwright.config.ts starts the Vite dev server itself. Two details matter:
- Port. Locally it is a fixed
5173, soreuseExistingServercan attach to a server you already have running — the biggest saving in the edit-test loop. UnderCIit asks the kernel for a free ephemeral port instead. The self-hosted runners are separate users on one host and therefore share a loopback interface, so a fixed port is a single global resource: two E2E jobs anywhere in the repository used to collide on it, and the second to start failed before running a test. SetE2E_PORTto pin a port yourself. - Workers. Four by default, overridable with
E2E_WORKERS. Every test mocks its own API surface throughpage.routeand gets its own browser context, and workers are separate processes, so nothing is shared but the dev server — which only serves modules.
9) VS Code notes (pragmatic expectations)
- OXC provides a VS Code extension
- Binary auto-detection may not work in all environments
- Formatting-on-save is optional
If formatting on save is unreliable:
- run
npm run formatmanually - rely on pre-commit and CI for enforcement
Guarantees
- formatting is deterministic
- linting catches correctness/suspicious/perf issues
- TypeScript errors are blocked via
tsc --noEmit - pre-commit and CI enforce the same rules
- no ESLint/Prettier configuration exists except
eslint.tailwind.config.mjs(required for Tailwind class linting) - contributors do not need to think about style decisions
Final note (important for Copilot)
When generating UI code:
- follow TypeScript strictness
- do not rely on ESLint-specific patterns
- assume formatting will be handled automatically by Oxfmt
- treat lint warnings as signals, not noise