- TypeScript 86%
- CSS 12.6%
- HTML 0.8%
- Dockerfile 0.6%
| .agents | ||
| .claude | ||
| public | ||
| src | ||
| .dockerignore | ||
| .gitignore | ||
| .oxlintrc.json | ||
| Dockerfile | ||
| index.html | ||
| nginx.conf | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.app.json | ||
| tsconfig.json | ||
| tsconfig.node.json | ||
| vite.config.ts | ||
UnitHub
A fast, exact, fully client-side unit converter — 18 categories, 236 units, in English and Portuguese. Built so that adding a unit is one line of data, not a component change.
Conversions you can check.
Nothing is sent anywhere. There is no backend, no analytics, and no third-party request of any kind — the fonts are self-hosted precisely so that stays true.
What it does
- Bidirectional conversion — type in either field, the other recomputes.
- Shareable URLs —
/:category?from=km&to=mi&v=10, restored exactly on open, in any language. - Signature scale ruler — a dual-graduation ruler where a value and its conversion land on the same physical point.
- All-units table — every unit at once, with per-row copy of the unrounded value.
- English and Portuguese (BR) — number formatting follows the language; the URL never does, so a shared link resolves identically for everyone.
- Dark / light, mobile-first, keyboard-navigable,
prefers-reduced-motionrespected.
Kept honest
The reason this exists. Most converters quietly flatten distinctions that change the answer:
- KB (×1000) vs KiB (×1024) are separate units, each with a tooltip. Same for bits vs bytes in data rate.
- A troy ounce is heavier than an ounce, but a troy pound is lighter than a pound. Both sets ship, both labelled.
- Torque is not energy.
lbf·ftandft·lbhave byte-identical factors, and they still live in different categories, because a moment is not work. - Out-of-gamut colours are reported, not hidden. OKLCH can name colours sRGB cannot show; converting one clips it, and the swatch says so and shows the ΔE it cost.
- CMYK ships flagged. Without an ICC profile the numbers are a convention, and the UI says that rather than implying a measurement.
- Base 58 is deliberately absent. Bitcoin's base58 omits
0,O,Iandl, so a plain positional base 58 would be subtly wrong rather than merely approximate.
Categories
| Group | Categories |
|---|---|
| Size & space | distance (27), area (12), volume (37), angle (8) |
| Mass & motion | weight (26), speed (7), force (12), torque (9), pressure (11) |
| Time & frequency | time (13), frequency (9) |
| Energy & heat | energy (12), power (11), temperature (4) |
| Data | file size (12), data rate (12) |
| Dev tools | number base (8 presets, any radix 2–64), color (6 models) |
Architecture
Three engine shapes, each routing through a hub the way a category routes through its base unit:
| Kind | Hub | Component |
|---|---|---|
scale |
the category's base unit | Converter |
radix |
a BigInt, exact at any size |
BaseConverter |
color |
linear-light sRGB, unclamped | ColorConverter |
target = value × factor(source) ÷ factor(target)
src/data/— categories as data. Thegroupsregistry indata/index.tsdrives the menu, the home page and the routes, so they cannot fall out of sync. Non-linear categories (temperature) usetoBase/fromBaseinstead offactor.src/lib/— pure, fully tested, no React:convert.ts(engine),numberBase.ts(radix 2–64 via BigInt),format.ts(parsing and Intl formatting),urlState.ts(state ↔ query),color/(the colour engine).src/i18n/— every user-visible string. A locale is one file plus one registry entry, and a missing key is a compile error, not a silent English fallback.src/components/,src/pages/— the UI.
Add a unit
// src/data/categories/distance.ts
{ id: "fur", factor: 201.168, symbol: "fur", labelKey: "units.fur" }
// src/i18n/en.ts (and pt.ts — a gap fails the test suite)
"units.fur": "Furlong",
That's it. The select, the ruler, the all-units table and the menu search all pick it up. No component is touched.
Develop
npm install
npm run dev # http://localhost:5173/unithub/
npm test # 115 tests — engine, formatting, URL round-trips, i18n coverage
npm run build # tsc + vite → dist/
npm run preview # serves the real build at :4173
Deploy
Static SPA behind nginx, served under the /unithub subpath, and the subpath
is real inside the container — the container path matches the public path 1:1.
docker build -t unithub .
docker run -p 8080:80 unithub # → http://localhost:8080/unithub/
On Dokploy: build from the Dockerfile, then set the domain to Host
app.lucaskalil.com, Path /unithub, Strip Path = OFF, Container Port 80.
Strip Path = ON is the one setting that breaks this. Traefik would forward
/and nginx would 404 every request.
The path is written in exactly one place — base in vite.config.ts. Everything
else reads it back through import.meta.env.BASE_URL (src/lib/basePath.ts), so
moving the app is a one-line change.
Deploys are manual from the Dokploy UI, by choice: nothing here needs to ship on every push.
Measured
Lighthouse against the live URL:
| Mobile | Desktop | |
|---|---|---|
| Performance | 98 | 100 |
| Accessibility | 100 | 100 |
| Best Practices | 100 | 100 |
| SEO | 100 | 100 |
Stack
React 19 · TypeScript · Vite · React Router · lucide-react · Vitest.
Fonts are self-hosted variable woff2 — Space Grotesk (display), Inter (UI), JetBrains Mono (numbers) — all OFL licensed. Linking them from Google cost 912 ms of render-blocking time and two third-party origins per visit.