Interactive wheel and tyre fitment calculator. Enter your current and new wheel/tyre specs and get an instant side-by-side comparison — measurements table plus a live 2D cross-section diagram showing both setups overlaid on a shared hub reference.
- Comparison table — diameter, circumference, poke, inset, speedo error, ride height gain, arch gap loss
- Speedometer correction — shows actual speed at reference speeds (mph for UK/US, km/h for all other locales)
- Spacer support — adjusts effective ET automatically; reflected in diagram and poke calculation
- Camber rendering — enter positive or negative degrees and the wheel tilts in the diagram
- Two 2D diagrams — a cross-section (overlaid tyre profiles on a shared hub, with diameter callouts and poke rows) plus a face-on view (both wheels as multi-spoke alloys with per-wheel spoke count/width controls, camber foreshortened into an ellipse, centre bores and hexagonal bolt heads drawn to scale)
- Hover tooltips — plain-English explanation of every measurement
- Offline / PWA — installable on desktop and mobile; works fully without a network connection after first load
- Fully accessible — skip link, labelled form groups,
role="status"live region, axe-core clean - Localised — 23 languages with browser auto-detection, per-locale HTML, hreflang, and full RTL support (Arabic, Hebrew, Urdu)
- Input validation — all fields have enforced min/max ranges; JS clamping backs up browser constraints
- Fitment warnings — amber/red flags with a plain-English reason: speedo under-reading, large rolling-diameter change, poke/inset clearance, centre-bore mismatch and bolt-pattern mismatch (row tints) plus tyre stretch/bulge, low profile, large spacers and aggressive camber (warning strip)
- Hardened headers — CSP, HSTS, COOP, and frame control shipped via
_headers(Cloudflare) and the Caddyfile (Docker) - Zero client dependencies — pure vanilla JS, no framework
npm install
npm start # → http://localhost:3000Requires Caddy (brew install caddy on macOS) if not using Docker:
npm run caddy # → http://localhost:80
# or with Docker:
docker compose upnpm run publish
public/is a build artifact — it is gitignored, not committed. Cloudflare (and CI, Docker, and the test suite) regenerates it fromsrc/withnpm run publish. Run the build once locally beforenpm start,docker build, or servingpublic/directly.
Reads src/, runs these steps in order, and writes everything to public/:
- CSS — minified with clean-css, then inlined into the HTML (eliminates the render-blocking request)
- JS — minified + mangled with Terser; written with a content hash in the filename — one file shared by all locales
- HTML — one page per locale: SEO tags + hreflang injected,
window.Llocale object inlined, JS filename +deferinjected, then minified - Service worker — cache name and precache list (root + all locale paths) injected, then minified
- Manifest + icon — copied verbatim
_headers— cache rules per locale path plus a/*block of security headers (CSP, HSTS, COOP, frame control)
locales en, ar, bn, da, de, es, fi, fr, he, hi, hr, it, ja, ko, nl, no, pl, pt, ro, sv, uk, ur, zh
style.css 11.8 KiB -> 7.3 KiB -38% (inlined)
app.js 25.1 KiB -> 10.8 KiB -57% -> app.<hash>.js
en/index.html 11.9 KiB -> 21.7 KiB
ar/index.html 11.9 KiB -> 23.6 KiB
...
sw.js 1.4 KiB -> 0.7 KiB -49%
✓ Done in ~230ms
The HTML grows because minified CSS and the locale object (
window.L) are inlined — total bytes in one round-trip instead of two.
Languages are defined in src/locales/<lang>.json. The build picks up every .json file in that directory automatically.
To add a new language:
- Copy
src/locales/en.jsontosrc/locales/sv.json(or any BCP 47 code) - Translate every string value — keep all keys present (the test suite enforces completeness)
- Set
"speedUnit","refSpeed1","refSpeed2"appropriately ("mph"+30/60for imperial countries;"km/h"+50/100for metric) - Add the flag emoji to
"flag"and the native language name to"langName" - Run
npm run publish— the new locale page, hreflang tags, and switcher option appear automatically
Right-to-left rendering is automatic: the build sets <html dir="rtl"> for Arabic, Hebrew, Urdu, and Farsi, and the CSS uses logical properties so the layout mirrors with no per-locale work.
URL structure (23 locales — English at the root, each other under its code):
| Locale | URL |
|---|---|
| English (default) | / |
| German | /de/ |
| French | /fr/ |
| Japanese | /ja/ |
| Arabic (RTL) | /ar/ |
| … | /<lang>/ |
Auto-detection: On first visit to /, the site reads navigator.language. If the browser locale matches a supported language it redirects once and stores the preference in localStorage. The language switcher (top-right of header) overrides this at any time.
npm testBuilds the project, installs Playwright Chromium if needed, then runs all specs. Requires no running server — the Playwright specs start their own static file server on :3334.
| File | What it covers |
|---|---|
test/locales.spec.js |
Locale JSON completeness (all required keys, speed units, placeholders, no empty strings); build output HTML (lang/dir attrs, window.L values, hreflang tags + exact href targets, redirect scripts, shared JS bundle); _headers cache + security headers (CSP, HSTS, COOP, frame control, Cloudflare Analytics allowances) |
test/calculator.spec.js |
Default inputs and values; input constraint attributes; results table (row count, OD, poke, labels, speedo precision); optional centre-bore and bolt-pattern rows (current/new/difference + adaptability warnings); boundary calculations at min/max limits; out-of-range clamping; spacer maths; fitment warnings (row tints + stretch/bulge strip); both canvas diagrams (cross-section + face-on view); tooltips; share button URL round-trip; URL parameter pre-fill |
test/i18n.spec.js |
Per-locale rendering (lang attr, h1, button labels, speed unit/reference value, tooltip-attribute escaping, no JS errors); language switcher (open/close, all locales listed, active state, Escape key, click-outside) |
test/autodetect.spec.js |
Auto-detection on /: browser locale and stored preference redirect to the right locale; English/unsupported stay on / |
test/csp.spec.js |
Loads pages under the exact production CSP from _headers and asserts the app triggers zero policy violations (LTR + RTL) |
test/fixtures.js exports a test object extended with two fixtures used by calculator.spec.js and i18n.spec.js:
server(worker-scoped) — starts a Nodehttp.createServeron:3334servingpublic/, shared across all tests in the worker, torn down when the worker exitsmakePage(lang)— creates an isolated browser context withlocalStoragepinned tolang(prevents auto-detect redirects) andnavigator.clipboard/navigator.sharestubbed for headless compatibility
npm run a11y builds, starts its own server, and runs the axe-core scan — no separate dev server needed:
npm run a11y # scans http://localhost:3000
npm run a11y -- https://fixthatgap.com # scans productionEdit site.config.json to configure metadata injected at build time:
{
"canonicalUrl": "https://fixthatgap.com",
"ogImage": "https://fixthatgap.com/icon.svg",
"twitterCard": "summary",
"author": "",
"keywords": "..."
}Per-locale title and description come from src/locales/<lang>.json (pageTitle and metaDescription). All other fields fall back to site.config.json.
npm run publish
docker build -t fitment-calculator .
docker run --rm -p 80:80 fitment-calculator
# or
docker compose upThe container uses Caddy with gzip/zstd compression and security headers.
Deployment uses Wrangler. The root wrangler.jsonc points Cloudflare at the built public/ directory (assets.directory).
npm run publish # build src/ → public/
npm run deploy # wrangler deploy (npm run preview for a local Cloudflare preview)_headers is honoured by Cloudflare for cache and security headers; Cloudflare handles CDN and HTTPS. Connecting the repo to Cloudflare's Git integration also works — set the build command to npm run publish and the output directory to public.
fitment-calculator/
├── src/ # source — edit these
│ ├── app.js # calculator logic + canvas rendering
│ ├── sw.js # service worker template
│ ├── index.html # markup template with {{KEY}} placeholders
│ ├── style.css # all styles
│ ├── manifest.json # Web App Manifest
│ ├── icon.svg # app icon (favicon + PWA)
│ └── locales/ # one JSON per language
│ ├── *.json
├── public/ # build output — gitignored, generated by npm run publish
│ ├── index.html # English; CSS inlined, window.L injected
│ ├── de/index.html # German
│ ├── fr/index.html # French (and so on)
│ ├── app.<hash>.js # shared minified JS
│ ├── sw.js
│ ├── manifest.json
│ ├── icon.svg
│ └── _headers # Cloudflare cache + security headers
├── test/
│ ├── fixtures.js # shared Playwright fixtures (server + makePage)
│ ├── locales.spec.js # locale JSON + build output + headers
│ ├── calculator.spec.js # calculator features + constraints + clamping
│ ├── i18n.spec.js # per-locale rendering + language switcher
│ └── csp.spec.js # app runs clean under the production CSP
├── build.js # build pipeline (terser + clean-css + html-minifier-terser)
├── playwright.config.js # Playwright test runner config
├── a11y.js # axe-core accessibility scanner
├── server.js # local dev — Express on :3000, serves public/
├── site.config.json # SEO metadata (canonical URL, OG, Twitter, keywords)
├── wrangler.jsonc # Cloudflare deploy config (assets dir: public/)
├── Caddyfile # Caddy config (local + Docker)
├── Dockerfile # production image — Caddy on :80
├── docker-compose.yml
├── package.json
├── biome.json # linter / formatter
├── .github/
│ └── workflows/
│ └── ci.yml # PR checks: lint → build → tests → a11y
├── .gitignore
└── .dockerignore
| Command | What it does |
|---|---|
npm start |
Build, then serve public/ via Express on :3000 |
npm run caddy |
Caddy server on :80 (serves the Docker /srv root) |
npm run check |
Build, then Biome lint + format |
npm run publish |
Build src/ → public/ for all locales |
npm test |
Build, install Chromium, run all Playwright specs |
npm run a11y |
Build, start a server, axe-core scan against :3000 |
npm run deploy |
wrangler deploy (publishes public/ to Cloudflare) |
npm run preview |
wrangler dev (local Cloudflare preview) |
.github/workflows/ci.yml runs on every pull request:
npx biome ci .— lint (no auto-fix, exits non-zero on violations)npm run publish— build all locale pagesnpx playwright install chromium --with-deps— install browser for testsnpx playwright test— full suite across locales, calculator, i18n, constraints, and CSPnode a11y.js— axe-core scan against the built site
| Field | Unit | Range | Notes |
|---|---|---|---|
| Rim diameter | inches | 12 – 25 | Standard automotive convention |
| Rim width | inches | 3 – 20 | |
| Offset / ET | mm | −300 – 300 | Positive = hub face toward outer edge; negative = hub face toward inner edge |
| Tyre width | mm | 100 – 500 | Section width |
| Profile | % | 10 – 100 | Aspect ratio (sidewall height as % of section width) |
| Spacer | mm | 0 – 100 | Optional — reduces effective ET by this amount |
| Camber | ° | −20 – +20 | Optional — positive or negative; tilts wheel in diagram |
| Centre bore | mm | 40 – 120 | Optional — wheel hub-bore diameter; drawn to scale in the face view and compared current-vs-new for fitment |
| Bolt pattern | — | dropdown | Optional — stud count × PCD (e.g. 5×114.3); drawn as hexagonal lugs on the PCD circle and compared for adaptability. Assumes 5×114.3 when unset |
All limits are enforced both by HTML min/max attributes (browser UI) and by JavaScript clamping in the v() input helper (calculation layer). Centre bore and bolt pattern are read raw (blank = unspecified) rather than clamped, since they are optional comparison-only fields.
| Metric | Formula |
|---|---|
| Total diameter | rimDiameter × 25.4 + 2 × (tyreWidth × profile / 100) |
| Poke | rimWidth / 2 − effectiveET |
| Inset | rimWidth / 2 + effectiveET |
| Effective ET | ET − spacer |
| Speedo error | (oldCirc − newCirc) / newCirc × 100 |
| Speedo reading at X mph/km/h | X × oldCirc / newCirc |
| Ride height gain | (newDiameter − oldDiameter) / 2 |