No description
  • TypeScript 86%
  • CSS 12.6%
  • HTML 0.8%
  • Dockerfile 0.6%
Find a file
2026-08-03 16:53:29 -03:00
.agents docs: record Lighthouse measurements and path configuration details 2026-08-03 16:53:29 -03:00
.claude feat(deploy): serve the app under the /unithub subpath 2026-07-28 17:24:59 -03:00
public feat(ui): build converters, ruler, and landing page 2026-07-22 00:14:54 -03:00
src docs: record Lighthouse measurements and path configuration details 2026-08-03 16:53:29 -03:00
.dockerignore chore(deploy): add Docker and nginx config for Dokploy 2026-07-22 00:15:00 -03:00
.gitignore chore: scaffold Vite + React + TypeScript project 2026-07-22 00:14:29 -03:00
.oxlintrc.json chore: scaffold Vite + React + TypeScript project 2026-07-22 00:14:29 -03:00
Dockerfile fix(docker): fix healthcheck probe to use explicit IP instead of localhost 2026-08-03 16:16:59 -03:00
index.html perf(fonts): self-host three font families, remove Google Fonts 2026-08-03 16:41:45 -03:00
nginx.conf feat(deploy): serve the app under the /unithub subpath 2026-07-28 17:24:59 -03:00
package-lock.json chore: scaffold Vite + React + TypeScript project 2026-07-22 00:14:29 -03:00
package.json chore: scaffold Vite + React + TypeScript project 2026-07-22 00:14:29 -03:00
README.md docs(README): expand guide with live link, categories, deploy instructions, and Lighthouse scores 2026-08-03 16:53:23 -03:00
tsconfig.app.json chore: scaffold Vite + React + TypeScript project 2026-07-22 00:14:29 -03:00
tsconfig.json chore: scaffold Vite + React + TypeScript project 2026-07-22 00:14:29 -03:00
tsconfig.node.json chore: scaffold Vite + React + TypeScript project 2026-07-22 00:14:29 -03:00
vite.config.ts feat(deploy): serve the app under the /unithub subpath 2026-07-28 17:24:59 -03:00

UnitHub

app.lucaskalil.com/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-motion respected.

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·ft and ft·lb have 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, I and l, 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 264), 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. The groups registry in data/index.ts drives the menu, the home page and the routes, so they cannot fall out of sync. Non-linear categories (temperature) use toBase / fromBase instead of factor.
  • src/lib/ — pure, fully tested, no React: convert.ts (engine), numberBase.ts (radix 264 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.