Unit Converter
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Two live inputs that chase each other: type on either side and the other one follows, because both sides read from a single amount held in one base unit — meters, kilograms, kelvin or bytes — so they can never drift apart. It opens on a marathon (42.195 km = 26.2188 mi) with the whole length ladder beside it, every unit showing the same distance at once; a chip next to the answer says whether the number is exact or rounded, and you can push it to 10 significant digits to see what was hidden. The real reason this one exists is the Data size tab, which refuses to paper over the kilobyte mess: a 1 TB drive is 931.323 GiB, not because bytes went missing but because 1,000 and 1,024 are different numbers, and a tooltip on the ? spells out how the gap compounds — 2.4% at kilo, 10% at tera. Try Temperature and type −40 into either box: it is the one reading where Celsius and Fahrenheit agree, and going below 0 K gets you told off.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Unit Converter — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---- extra surfaces (theme-aware, derived from the token set) ---- */
:root { --danger-soft: #FCEDEC; --danger-ink: #A82C28; --sunk: #FAFAF7; --accent-ink: #9A4500; --shadow: rgba(20,22,26,.16); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --danger-soft: #2D1A18; --danger-ink: #F47067; --sunk: #171A20; --accent-ink: #FFB454; --shadow: rgba(0,0,0,.55); }
}
:root[data-theme="dark"] { --danger-soft: #2D1A18; --danger-ink: #F47067; --sunk: #171A20; --accent-ink: #FFB454; --shadow: rgba(0,0,0,.55); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
[hidden] { display: none !important; }
.wrap { max-width: 1060px; margin: 0 auto; padding: 22px 16px 48px; }
.mono { font-family: var(--font-mono); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
button, input, select { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ---- header ---- */
header { margin-bottom: 16px; }
h1 { font-size: 1.35rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.lede { margin: 0 0 10px; color: var(--muted); max-width: 66ch; font-size: .95rem; }
.spec { display: flex; flex-wrap: wrap; gap: 6px; }
.spec b {
font-family: var(--font-mono); font-size: .7rem; font-weight: 500; letter-spacing: .02em;
padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px;
color: var(--muted); background: var(--surface); white-space: nowrap;
}
/* ---- cards ---- */
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
.card + .card { margin-top: 14px; }
.card h2 {
font-family: var(--font-mono); font-size: .72rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); margin: 0 0 10px;
}
.cardhead { display: flex; gap: 10px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; margin-bottom: 10px; }
.cardhead h2 { margin: 0; }
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; align-items: start; }
@media (min-width: 880px) { .grid { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); } }
/* ---- tabs ---- */
.tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.tab {
font-family: var(--font-mono); font-size: .76rem; padding: 7px 13px;
border: 1px solid var(--line); border-radius: 999px; background: var(--surface);
color: var(--muted); cursor: pointer;
}
.tab:hover { border-color: var(--accent); color: var(--ink); }
.tab[aria-selected="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
/* ---- converter ---- */
.convert { display: grid; gap: 12px; grid-template-columns: 1fr; align-items: end; }
@media (min-width: 620px) { .convert { grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); } }
.side { min-width: 0; }
.sidelab {
display: block; font-family: var(--font-mono); font-size: .64rem; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); margin-bottom: 5px;
}
input.num {
width: 100%; font-family: var(--font-mono); font-weight: 600; letter-spacing: -0.02em;
font-size: clamp(1.45rem, 6.4vw, 2.1rem); line-height: 1.2;
font-variant-numeric: tabular-nums;
padding: 8px 10px; background: var(--sunk); border: 1px solid var(--line);
border-radius: 8px; min-width: 0;
}
input.num:focus { border-color: var(--accent); }
input.num.bad { border-color: var(--danger); color: var(--danger); }
.selwrap { position: relative; margin-top: 7px; }
.selwrap select {
appearance: none; -webkit-appearance: none; width: 100%;
padding: 8px 30px 8px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--surface); font-family: var(--font-mono); font-size: .8rem; cursor: pointer;
}
.selwrap select:hover { border-color: var(--accent); }
.selwrap::after {
content: ""; position: absolute; right: 12px; top: 50%; width: 7px; height: 7px;
border-right: 1.6px solid var(--muted); border-bottom: 1.6px solid var(--muted);
transform: translateY(-72%) rotate(45deg); pointer-events: none;
}
.swapcell { display: flex; justify-content: center; padding-bottom: 26px; }
@media (max-width: 619px) { .swapcell { padding: 0; } }
.swap {
width: 44px; height: 44px; border-radius: 999px; border: 1px solid var(--line);
background: var(--surface); color: var(--muted); cursor: pointer;
display: flex; align-items: center; justify-content: center; flex: none;
}
.swap:hover { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); }
.swap svg { width: 18px; height: 18px; transform: rotate(90deg); }
@media (min-width: 620px) { .swap svg { transform: none; } }
.swap.spun svg { transform: rotate(270deg); }
@media (min-width: 620px) { .swap.spun svg { transform: rotate(180deg); } }
/* ---- equation readout ---- */
.eqwrap { margin-top: 14px; border-top: 1px dashed var(--line); padding-top: 12px; }
.cap { font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; color: var(--accent-ink); margin-bottom: 4px; }
.eqrow { display: flex; gap: 10px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; }
.eq {
font-size: clamp(.98rem, 3.6vw, 1.18rem); font-weight: 600; letter-spacing: -0.01em;
font-variant-numeric: tabular-nums; overflow-wrap: anywhere; min-width: 0;
}
.eqmeta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.chip {
font-size: .66rem; letter-spacing: .04em; padding: 3px 7px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--sunk); white-space: nowrap;
}
.chip.exact { color: var(--accent-ink); border-color: var(--accent); background: var(--accent-soft); }
.btn {
font-family: var(--font-mono); font-size: .72rem; padding: 5px 10px;
background: var(--surface); color: var(--muted);
border: 1px solid var(--line); border-radius: 7px; cursor: pointer; white-space: nowrap;
}
.btn:hover { border-color: var(--accent); color: var(--ink); }
.btn.ok { color: var(--run); border-color: var(--run); background: var(--run-soft); font-weight: 600; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn:disabled:hover { border-color: var(--line); color: var(--muted); }
/* ---- notes ---- */
.note, .warn { font-size: .82rem; color: var(--muted); margin: 12px 0 0; max-width: 70ch; }
.note { position: relative; }
.warn {
color: var(--danger-ink); background: var(--danger-soft); border: 1px solid var(--danger);
border-radius: 8px; padding: 7px 10px; font-family: var(--font-mono); font-size: .74rem;
}
.note b { font-family: var(--font-mono); font-weight: 600; color: var(--ink); font-size: .95em; }
/* ---- tooltip ---- */
.tipwrap { display: inline-block; vertical-align: baseline; }
.tipbtn {
position: relative; width: 19px; height: 19px; padding: 0; border-radius: 999px;
border: 1px solid var(--line); background: var(--surface); color: var(--muted); cursor: pointer;
font-family: var(--font-mono); font-size: .7rem; line-height: 1; vertical-align: -3px;
}
/* the dot stays small; the thing you actually have to hit does not */
.tipbtn::after { content: ""; position: absolute; inset: -6px; border-radius: 999px; }
.tipbtn:hover, .tipbtn[aria-expanded="true"] { border-color: var(--accent); color: var(--accent-ink); background: var(--accent-soft); }
.tip {
position: absolute; z-index: 5; left: 0; top: calc(100% + 8px);
width: min(320px, 100%); background: var(--surface); border: 1px solid var(--accent);
border-radius: 8px; padding: 10px 12px; box-shadow: 0 8px 24px var(--shadow);
font-size: .78rem; color: var(--muted); line-height: 1.5; text-align: left;
}
.tip b { display: block; font-family: var(--font-mono); font-size: .72rem; color: var(--ink); margin-bottom: 4px; }
.tip code { font-family: var(--font-mono); color: var(--ink); font-size: .95em; }
.tip .tipfoot { display: block; margin-top: 6px; font-family: var(--font-mono); font-size: .68rem; color: var(--accent-ink); }
/* ---- examples ---- */
.ex { display: flex; flex-wrap: wrap; gap: 7px; }
.exbtn {
border: 1px solid var(--line); background: var(--sunk); border-radius: 999px;
padding: 6px 11px; cursor: pointer; font-size: .8rem; color: var(--ink);
display: inline-flex; gap: 7px; align-items: baseline; max-width: 100%;
}
.exbtn:hover { border-color: var(--accent); background: var(--accent-soft); }
.exbtn .exv { font-family: var(--font-mono); font-size: .72rem; color: var(--muted); white-space: nowrap; }
.exbtn:hover .exv { color: var(--accent-ink); }
/* ---- ladder ---- */
.prec { display: flex; gap: 5px; align-items: center; }
.prec .plab { font-family: var(--font-mono); font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.pbtn {
font-family: var(--font-mono); font-size: .7rem; padding: 3px 8px; border-radius: 6px;
border: 1px solid var(--line); background: var(--surface); color: var(--muted); cursor: pointer;
}
.pbtn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
.ladder { display: grid; gap: 5px; }
.lrow {
display: grid; grid-template-columns: 4.4em minmax(0, 1fr) auto; gap: 10px; align-items: baseline;
width: 100%; text-align: left; background: var(--sunk); border: 1px solid var(--line);
border-radius: 8px; padding: 7px 10px; cursor: pointer; color: inherit;
}
.lrow:hover { border-color: var(--accent); }
.lrow[aria-disabled="true"] { cursor: default; }
.lrow[aria-disabled="true"]:hover { border-color: var(--accent); }
.lsym { font-family: var(--font-mono); font-size: .8rem; font-weight: 600; }
.lname { font-size: .76rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lval { font-family: var(--font-mono); font-size: .82rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.lrow[data-role="from"] { background: var(--surface); border-color: var(--muted); }
.lrow[data-role="to"] { background: var(--accent-soft); border-color: var(--accent); cursor: default; }
.lrow[data-role="to"] .lsym, .lrow[data-role="to"] .lval { color: var(--accent-ink); }
.ltag { font-family: var(--font-mono); font-size: .63rem; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin-left: 6px; }
.lrow[data-role="to"] .ltag { color: var(--accent-ink); }
@media (max-width: 459px) {
.lrow { grid-template-columns: 4.4em minmax(0, 1fr); row-gap: 2px; }
.lval { grid-row: 1; grid-column: 2; text-align: right; }
.lname { grid-row: 2; grid-column: 1 / -1; }
}
/* ---- explainer / footer ---- */
details { margin-top: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 10px 14px; }
summary { cursor: pointer; font-family: var(--font-mono); font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
details p { font-size: .86rem; color: var(--muted); max-width: 74ch; }
details code { font-family: var(--font-mono); font-size: .92em; color: var(--ink); }
footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
@media (prefers-reduced-motion: no-preference) {
.btn, .tab, .lrow, .exbtn, .swap, input.num, .selwrap select, .tipbtn {
transition: background-color .14s ease, border-color .14s ease, color .14s ease;
}
.swap svg { transition: transform .3s cubic-bezier(.3,.9,.3,1); }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Unit Converter</h1>
<p class="lede">Type on either side — the other one follows. Length, mass, temperature and data size, with the kilobyte-versus-kibibyte mess spelled out instead of quietly rounded away.</p>
<div class="spec"><b id="specUnits">units</b><b>4 categories</b><b>runs offline</b><b>no setup</b></div>
</header>
<div class="tabs" role="tablist" aria-label="Category" id="tabs"></div>
<div class="grid" role="tabpanel" id="panel" tabindex="-1">
<div>
<section class="card" aria-labelledby="convLabel">
<h2 id="convLabel">Convert</h2>
<div class="convert">
<div class="side">
<label class="sidelab" for="valA">from</label>
<input class="num" id="valA" type="text" inputmode="decimal" autocomplete="off" spellcheck="false" autocapitalize="off">
<label class="sr" for="unitA">unit to convert from</label>
<div class="selwrap"><select id="unitA"></select></div>
</div>
<div class="swapcell">
<button class="swap" id="swap" type="button" title="Swap the two units">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 8h13"></path><path d="M13 4l4 4-4 4"></path>
<path d="M20 16H7"></path><path d="M11 20l-4-4 4-4"></path>
</svg>
<span class="sr">Swap the two units</span>
</button>
</div>
<div class="side">
<label class="sidelab" for="valB">to</label>
<input class="num" id="valB" type="text" inputmode="decimal" autocomplete="off" spellcheck="false" autocapitalize="off">
<label class="sr" for="unitB">unit to convert to</label>
<div class="selwrap"><select id="unitB"></select></div>
</div>
</div>
<div class="eqwrap">
<div class="cap mono" id="cap"></div>
<div class="eqrow">
<div class="eq mono" id="eq" aria-live="polite" aria-atomic="true"></div>
<div class="eqmeta">
<span class="chip mono" id="prec"></span>
<button class="btn" id="copy" type="button">copy</button>
</div>
</div>
</div>
<p class="note" id="note">
<span id="noteText"></span><span class="tipwrap" id="tipwrap" hidden> <button class="tipbtn" id="tipBtn" type="button" aria-expanded="false" aria-controls="tip">?<span class="sr">Explain the difference between KB and KiB</span></button><span class="tip" id="tip" hidden><b>kB vs KiB</b>A <code>kB</code> is 1,000 bytes — the decimal prefix that drive makers, network speeds and phone plans use. A <code>KiB</code> is 1,024 bytes — the binary prefix your operating system counts in. Both are correct; they just count differently, and the gap compounds: 2.4% at kilo, 4.9% at mega, 7.4% at giga, 10% at tera.<span class="tipfoot">1 KiB = 1.024 kB</span></span></span>
</p>
<p class="warn mono" id="warn" role="status" hidden></p>
</section>
<section class="card" aria-labelledby="exLabel">
<h2 id="exLabel">Try one</h2>
<div class="ex" id="ex"></div>
</section>
</div>
<section class="card" aria-labelledby="ladLabel">
<div class="cardhead">
<h2 id="ladLabel">The same amount, every unit</h2>
<div class="prec" role="group" aria-label="Significant digits">
<span class="plab">digits</span>
<button class="pbtn" type="button" data-sig="4" aria-pressed="false">4</button>
<button class="pbtn" type="button" data-sig="6" aria-pressed="true">6</button>
<button class="pbtn" type="button" data-sig="10" aria-pressed="false">10</button>
</div>
</div>
<div class="ladder" id="ladder"></div>
</section>
</div>
<details>
<summary>How it works</summary>
<p>Every amount is stored once in a base unit — meters for length, kilograms for mass, bytes for data,
kelvin for temperature — then converted back out for each unit on screen. Both sides read from that one
number, so they can never drift apart no matter which side you type into.</p>
<p>The factors are the exact defined ones, not approximations: <code>1 in = 25.4 mm</code>,
<code>1 lb = 0.45359237 kg</code>, <code>1 nmi = 1852 m</code>, <code>1 KiB = 1024 B</code>.
Temperature is the odd one out — it shifts as well as scales
(<code>K = °C + 273.15</code>, <code>K = (°F + 459.67) × 5/9</code>), which is why doubling 20 °C does not
give you twice the heat.</p>
<p>Results are rounded to the chosen number of significant digits, then trailing zeros are dropped. The
chip next to the answer says <code>exact</code> when nothing was hidden by that rounding, and
<code>rounded</code> when digits were dropped. Very large and very small results switch to exponent form
rather than printing a wall of zeros.</p>
</details>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
/* ---------- unit tables ---------- */
function ratio(sym, name, f, extra) {
return {
sym: sym, name: name, f: f, extra: extra || "",
toBase: function (v) { return v * f; },
fromBase: function (b) { return b / f; }
};
}
var CATS = [
{
id: "length", label: "Length", base: "m",
units: [
ratio("mm", "millimeter", 0.001),
ratio("cm", "centimeter", 0.01),
ratio("in", "inch", 0.0254),
ratio("ft", "foot", 0.3048),
ratio("yd", "yard", 0.9144),
ratio("m", "meter", 1),
ratio("km", "kilometer", 1000),
ratio("mi", "mile", 1609.344),
ratio("nmi", "nautical mile", 1852)
],
start: { v: "42.195", from: "km", to: "mi", cap: "a marathon" },
examples: [
{ cap: "a marathon", v: "42.195", from: "km", to: "mi" },
{ cap: "the tallest mountain", v: "8848.86", from: "m", to: "ft" },
{ cap: "a sheet of paper", v: "0.1", from: "mm", to: "in" },
{ cap: "a six-foot person", v: "6", from: "ft", to: "cm" }
],
note: "Length is stored in meters. Every factor here is exact by definition, including the awkward ones: an inch is exactly 25.4 mm."
},
{
id: "mass", label: "Mass", base: "kg",
units: [
ratio("mg", "milligram", 1e-6),
ratio("g", "gram", 0.001),
ratio("oz", "ounce", 0.028349523125),
ratio("lb", "pound", 0.45359237),
ratio("kg", "kilogram", 1),
ratio("st", "stone", 6.35029318),
ratio("ton", "short ton", 907.18474),
ratio("t", "tonne", 1000)
],
start: { v: "150", from: "t", to: "lb", cap: "a blue whale" },
examples: [
{ cap: "a blue whale", v: "150", from: "t", to: "lb" },
{ cap: "a paperclip", v: "1", from: "g", to: "oz" },
{ cap: "a bag of sugar", v: "1", from: "kg", to: "lb" },
{ cap: "one stone", v: "1", from: "st", to: "kg" }
],
note: "Mass is stored in kilograms. Watch the two tons: a tonne is 1,000 kg, a short ton is 2,000 lb — they differ by about 10%."
},
{
id: "temp", label: "Temperature", base: "K",
units: [
{ sym: "°C", name: "Celsius", extra: "", toBase: function (v) { return v + 273.15; }, fromBase: function (b) { return b - 273.15; } },
{ sym: "°F", name: "Fahrenheit", extra: "", toBase: function (v) { return (v + 459.67) * 5 / 9; }, fromBase: function (b) { return b * 9 / 5 - 459.67; } },
{ sym: "K", name: "kelvin", extra: "", toBase: function (v) { return v; }, fromBase: function (b) { return b; } },
{ sym: "°R", name: "Rankine", extra: "", toBase: function (v) { return v * 5 / 9; }, fromBase: function (b) { return b * 9 / 5; } }
],
start: { v: "200", from: "°C", to: "°F", cap: "a hot oven" },
examples: [
{ cap: "a hot oven", v: "200", from: "°C", to: "°F" },
{ cap: "body heat", v: "37", from: "°C", to: "°F" },
{ cap: "a cold morning", v: "-10", from: "°C", to: "°F" },
{ cap: "absolute zero", v: "0", from: "K", to: "°C" }
],
note: "Temperature scales are offset, not just scaled — so −40 is the one reading where Celsius and Fahrenheit agree. Try it."
},
{
id: "data", label: "Data size", base: "B",
units: [
ratio("bit", "bit", 0.125, "8 bits = 1 byte"),
ratio("B", "byte", 1, ""),
ratio("kB", "kilobyte", 1e3, "1,000 B"),
ratio("KiB", "kibibyte", 1024, "1,024 B"),
ratio("MB", "megabyte", 1e6, "1,000 kB"),
ratio("MiB", "mebibyte", 1048576, "1,024 KiB"),
ratio("GB", "gigabyte", 1e9, "1,000 MB"),
ratio("GiB", "gibibyte", 1073741824, "1,024 MiB"),
ratio("TB", "terabyte", 1e12, "1,000 GB"),
ratio("TiB", "tebibyte", 1099511627776, "1,024 GiB"),
ratio("PB", "petabyte", 1e15, "1,000 TB"),
ratio("PiB", "pebibyte", 1125899906842624, "1,024 TiB")
],
start: { v: "1", from: "TB", to: "GiB", cap: "a 1 TB drive" },
examples: [
{ cap: "a 1 TB drive", v: "1", from: "TB", to: "GiB" },
{ cap: "a DVD", v: "4.7", from: "GB", to: "GiB" },
{ cap: "a floppy disk", v: "1440", from: "KiB", to: "MB" },
{ cap: "one photo", v: "4", from: "MiB", to: "MB" }
],
note: ""
}
];
/* data system tags, for the kB / KiB story */
var SYS = {
kB: { s: "dec", t: 1 }, KiB: { s: "bin", t: 1 },
MB: { s: "dec", t: 2 }, MiB: { s: "bin", t: 2 },
GB: { s: "dec", t: 3 }, GiB: { s: "bin", t: 3 },
TB: { s: "dec", t: 4 }, TiB: { s: "bin", t: 4 },
PB: { s: "dec", t: 5 }, PiB: { s: "bin", t: 5 }
};
var byId = {};
var totalUnits = 0;
CATS.forEach(function (c) {
byId[c.id] = c;
c.map = {};
c.units.forEach(function (u) { c.map[u.sym] = u; });
totalUnits += c.units.length;
});
/* ---------- number formatting ---------- */
function group(s) {
var neg = s.charAt(0) === "-";
if (neg) s = s.slice(1);
var dot = s.indexOf(".");
var ip = dot < 0 ? s : s.slice(0, dot);
var rest = dot < 0 ? "" : s.slice(dot);
if (ip.length > 4) ip = ip.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return (neg ? "-" : "") + ip + rest;
}
function trimZeros(s) {
if (s.indexOf(".") < 0) return s;
return s.replace(/0+$/, "").replace(/\.$/, "");
}
/* returns { text, plain, exact } */
function fmt(v, sig) {
if (v === null || v === undefined || typeof v !== "number" || !isFinite(v)) {
return { text: "—", plain: "", exact: false };
}
if (v === 0) return { text: "0", plain: "0", exact: true };
var ref = Number(v.toPrecision(12)); /* reference for "did rounding hide anything" */
/* branch on the ROUNDED value, so something like 999999999999999 at 6 s.f.
becomes 1e+15 instead of a sixteen-digit wall of zeros */
var r = Number(v.toPrecision(sig));
var a = Math.abs(r), plain;
if (a >= 1e15 || a < 1e-6) {
var parts = v.toExponential(Math.max(1, sig - 1)).split("e");
plain = trimZeros(parts[0]) + "e" + parts[1];
return { text: plain, plain: plain, exact: ref === Number(plain) };
}
var mag = Math.floor(Math.log10(a));
var dec = Math.max(0, Math.min(15, sig - 1 - mag));
plain = trimZeros(r.toFixed(dec));
return {
text: group(plain),
plain: plain,
exact: ref === Number(plain)
};
}
/* null = nothing to convert yet (empty, or half-typed like "-" or "1e");
NaN = genuinely unreadable; ±Infinity = numeric but past what a double can hold */
function parseNum(raw) {
if (raw === null || raw === undefined) return null;
var s = String(raw).replace(/[,\s ]/g, "").replace(/−/g, "-");
if (s === "" || s === "-" || s === "." || s === "-.") return null;
/* mid-keystroke exponent — "1e", "1e-" — is unfinished, not an error */
if (/^-?(\d+\.?\d*|\.\d+)e[-+]?$/i.test(s)) return null;
if (!/^-?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(s)) return NaN;
return parseFloat(s);
}
/* ---------- state ---------- */
var state = { cat: "length", sig: 6, per: {} };
CATS.forEach(function (c) {
state.per[c.id] = {
base: c.map[c.start.from].toBase(parseNum(c.start.v)),
from: c.start.from, to: c.start.to, cap: c.start.cap, src: "a"
};
});
var el = {};
["tabs", "panel", "valA", "valB", "unitA", "unitB", "swap", "cap", "eq", "prec",
"copy", "note", "noteText", "tipwrap", "tipBtn", "tip", "warn", "ex", "ladder",
"specUnits", "convLabel"].forEach(function (k) { el[k] = document.getElementById(k); });
function cur() { return state.per[state.cat]; }
function cat() { return byId[state.cat]; }
/* ---------- tabs ---------- */
CATS.forEach(function (c, i) {
var b = document.createElement("button");
b.className = "tab";
b.type = "button";
b.id = "tab-" + c.id;
b.setAttribute("role", "tab");
b.setAttribute("aria-selected", i === 0 ? "true" : "false");
b.setAttribute("aria-controls", "panel");
b.tabIndex = i === 0 ? 0 : -1;
b.textContent = c.label;
b.addEventListener("click", function () { setCat(c.id); });
el.tabs.appendChild(b);
});
el.tabs.addEventListener("keydown", function (e) {
var keys = { ArrowLeft: -1, ArrowRight: 1, Home: "h", End: "e" };
if (!Object.prototype.hasOwnProperty.call(keys, e.key)) return;
e.preventDefault();
var idx = CATS.map(function (c) { return c.id; }).indexOf(state.cat);
var n;
if (e.key === "Home") n = 0;
else if (e.key === "End") n = CATS.length - 1;
else n = (idx + keys[e.key] + CATS.length) % CATS.length;
setCat(CATS[n].id);
document.getElementById("tab-" + CATS[n].id).focus();
});
function setCat(id) {
state.cat = id;
closeTip();
CATS.forEach(function (c) {
var t = document.getElementById("tab-" + c.id);
var on = c.id === id;
t.setAttribute("aria-selected", on ? "true" : "false");
t.tabIndex = on ? 0 : -1;
});
el.panel.setAttribute("aria-labelledby", "tab-" + id);
buildSelects();
buildExamples();
render();
}
/* ---------- selects ---------- */
function buildSelects() {
[el.unitA, el.unitB].forEach(function (sel) {
sel.textContent = "";
cat().units.forEach(function (u) {
var o = document.createElement("option");
o.value = u.sym;
o.textContent = u.sym + " · " + u.name;
sel.appendChild(o);
});
});
}
/* ---------- examples ---------- */
function buildExamples() {
el.ex.textContent = "";
cat().examples.forEach(function (x) {
var b = document.createElement("button");
b.className = "exbtn";
b.type = "button";
var n = document.createElement("span");
n.textContent = x.cap;
var v = document.createElement("span");
v.className = "exv";
v.textContent = x.v + " " + x.from + " → " + x.to;
b.appendChild(n);
b.appendChild(v);
b.addEventListener("click", function () {
var s = cur();
s.from = x.from; s.to = x.to; s.cap = x.cap; s.src = "a";
s.base = cat().map[x.from].toBase(parseNum(x.v));
render();
});
el.ex.appendChild(b);
});
}
/* ---------- data-size story ---------- */
function dataNote() {
var s = cur();
var a = SYS[s.from], b = SYS[s.to];
/* "=" only when the printed figure really is the whole number */
function eqs(v, sig) { var f = fmt(v, sig); return (f.exact ? "= " : "≈ ") + f.text; }
if (a && b && a.s !== b.s) {
var dec = a.s === "dec" ? s.from : s.to;
var bin = a.s === "dec" ? s.to : s.from;
var one = cat().map[dec].f / cat().map[bin].f;
if (a.t === b.t) {
var gap = Math.round((1 / one - 1) * 1000) / 10;
return "Same tier, different counting: 1 " + dec + " " + eqs(one, 4) + " " + bin +
" — the binary unit is " + fmt(gap, 4).text + "% bigger. That gap compounds every tier you " +
"climb: 2.4% at kilo, 4.9% at mega, 7.4% at giga, 10% at tera.";
}
if (SYS[dec].t === SYS[bin].t + 1) {
return "The classic mismatch: 1 " + dec + " on the box becomes " + fmt(one, 6).text + " " + bin +
" once your computer counts in 1,024s. Not a single byte went missing — only the naming changed.";
}
var big = one >= 1 ? dec : bin, small = one >= 1 ? bin : dec;
var many = one >= 1 ? one : 1 / one;
return "Two counting systems side by side: 1 " + big + " " + eqs(many, 6) + " " + small +
". Decimal prefixes step by 1,000, binary ones by 1,024, and the gap compounds every step.";
}
return "Decimal units count in 1,000s (kB, MB, GB). Binary units count in 1,024s (KiB, MiB, GiB). Both are real units; the gap between them compounds with every step up.";
}
/* ---------- render ---------- */
function convert(base, sym) {
if (base === null) return null;
var u = cat().map[sym];
return u ? u.fromBase(base) : null;
}
var typeErr = "";
var rangeErr = "";
/* one place decides what the red strip says */
function paintWarn() {
var c = cat(), s = cur(), msg = "";
if (typeErr) msg = typeErr;
else if (rangeErr) msg = rangeErr;
else if (c.id === "temp" && s.base !== null && s.base < 0) {
msg = "that is below absolute zero (0 K) — no such temperature exists";
}
el.warn.hidden = !msg;
el.warn.textContent = msg;
}
function render(skip) {
var c = cat(), s = cur();
el.unitA.value = s.from;
el.unitB.value = s.to;
var va = convert(s.base, s.from);
var vb = convert(s.base, s.to);
var fa = fmt(va, state.sig);
var fb = fmt(vb, state.sig);
/* a value can be finite in the base unit and still overflow a much smaller one */
var over = s.base !== null && (!isFinite(va) || !isFinite(vb));
rangeErr = over
? "that amount is too large to write in " + (!isFinite(va) ? s.from : s.to) + " — pick a bigger unit"
: "";
if (skip !== "a") { el.valA.value = s.base === null ? "" : fa.text; el.valA.classList.remove("bad"); }
if (skip !== "b") { el.valB.value = s.base === null ? "" : fb.text; el.valB.classList.remove("bad"); }
/* equation */
if (s.base === null || over) {
el.eq.textContent = "—";
el.prec.textContent = s.base === null ? "waiting for a number" : "out of range";
el.prec.className = "chip mono";
el.copy.disabled = true;
} else {
el.eq.textContent = fa.text + " " + s.from + " = " + fb.text + " " + s.to;
var exact = fb.exact;
el.prec.textContent = exact ? "exact" : "rounded · " + state.sig + " s.f.";
el.prec.className = "chip mono" + (exact ? " exact" : "");
el.copy.disabled = false;
}
/* caption */
el.cap.textContent = s.cap || "";
el.cap.hidden = !s.cap;
/* note */
var noteTxt = c.id === "data" ? dataNote() : c.note;
el.noteText.textContent = noteTxt ? noteTxt + " " : "";
el.tipwrap.hidden = c.id !== "data";
el.note.hidden = !noteTxt;
typeErr = "";
paintWarn();
renderLadder();
}
function renderLadder() {
var c = cat(), s = cur();
/* the rows are rebuilt from scratch, so remember where the keyboard was */
var act = document.activeElement;
var keep = act && act.classList && act.classList.contains("lrow")
? act.getAttribute("data-sym") : null;
el.ladder.textContent = "";
c.units.forEach(function (u) {
var b = document.createElement("button");
b.className = "lrow";
b.type = "button";
b.setAttribute("data-sym", u.sym);
var role = u.sym === s.from ? "from" : (u.sym === s.to ? "to" : "");
if (role) b.setAttribute("data-role", role);
var sym = document.createElement("span");
sym.className = "lsym";
sym.textContent = u.sym;
var name = document.createElement("span");
name.className = "lname";
name.textContent = u.name + (u.extra ? " · " + u.extra : "");
if (role) {
var tag = document.createElement("span");
tag.className = "ltag";
tag.textContent = role;
name.appendChild(tag);
}
var val = document.createElement("span");
val.className = "lval";
val.textContent = s.base === null ? "—" : fmt(u.fromBase(s.base), state.sig).text;
b.appendChild(sym); b.appendChild(name); b.appendChild(val);
if (role === "to") {
/* aria-disabled, not disabled: it must stay reachable so keyboard focus
can land back on it after the list is rebuilt */
b.setAttribute("aria-disabled", "true");
b.setAttribute("aria-label", "already showing the result in " + u.name);
} else if (role === "from") {
b.setAttribute("aria-label", "swap — convert from " + u.name + " instead");
b.addEventListener("click", doSwap);
} else {
b.setAttribute("aria-label", "show the result in " + u.name);
b.addEventListener("click", function () {
s.to = u.sym;
if (s.src === "b") s.src = "a";
render();
});
}
el.ladder.appendChild(b);
});
if (keep) {
var back = el.ladder.querySelector('.lrow[data-sym="' + keep.replace(/"/g, '\\"') + '"]');
if (back) back.focus();
}
}
/* ---------- input wiring ---------- */
function onType(side) {
var s = cur();
var input = side === "a" ? el.valA : el.valB;
var unit = side === "a" ? s.from : s.to;
var n = parseNum(input.value);
s.src = side;
if (n === null) {
s.cap = "";
s.base = null;
input.classList.remove("bad");
render(side);
return;
}
if (isNaN(n)) {
input.classList.add("bad");
typeErr = "that is not a number I can read — try 12, 3.5 or 1e6";
paintWarn();
return;
}
if (!isFinite(n)) {
input.classList.add("bad");
typeErr = "that overflows what a browser number can hold — keep the exponent under 1e308";
paintWarn();
return;
}
var b = cat().map[unit].toBase(n);
if (!isFinite(b)) {
input.classList.add("bad");
typeErr = "that many " + unit + " overflows what a browser number can hold — try a bigger unit";
paintWarn();
return;
}
input.classList.remove("bad");
s.cap = "";
s.base = b;
render(side);
}
el.valA.addEventListener("input", function () { onType("a"); });
el.valB.addEventListener("input", function () { onType("b"); });
el.valA.addEventListener("blur", function () { render(); });
el.valB.addEventListener("blur", function () { render(); });
[el.valA, el.valB].forEach(function (i) {
i.addEventListener("focus", function () { i.select(); });
});
/* keep the number on the side you typed into, reinterpreted in the new unit —
read it back out of the base value so display rounding can never eat digits */
function reinterpret(oldSym, newSym) {
var s = cur();
if (s.base === null) return;
var b = cat().map[newSym].toBase(cat().map[oldSym].fromBase(s.base));
if (!isFinite(b)) return; /* re-reading it in that unit would overflow — keep the amount */
s.base = b;
s.cap = "";
}
el.unitA.addEventListener("change", function () {
var s = cur(), old = s.from;
s.from = el.unitA.value;
if (s.from === s.to) { s.to = old; }
if (s.src === "a") reinterpret(old, s.from);
render();
});
el.unitB.addEventListener("change", function () {
var s = cur(), old = s.to;
s.to = el.unitB.value;
if (s.to === s.from) { s.from = old; }
if (s.src === "b") reinterpret(old, s.to);
render();
});
function doSwap() {
var s = cur();
var t = s.from; s.from = s.to; s.to = t;
s.src = s.src === "a" ? "b" : "a";
el.swap.classList.toggle("spun");
render();
}
el.swap.addEventListener("click", doSwap);
/* ---------- precision ---------- */
Array.prototype.forEach.call(document.querySelectorAll(".pbtn"), function (b) {
b.addEventListener("click", function () {
state.sig = parseInt(b.getAttribute("data-sig"), 10);
Array.prototype.forEach.call(document.querySelectorAll(".pbtn"), function (o) {
o.setAttribute("aria-pressed", o === b ? "true" : "false");
});
render();
});
});
/* ---------- copy ---------- */
var copyTimer = null;
function flashCopied(ok) {
el.copy.textContent = ok ? "copied ✓" : "copy failed";
el.copy.classList.toggle("ok", !!ok);
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(function () {
el.copy.textContent = "copy";
el.copy.classList.remove("ok");
}, 1600);
}
function legacyCopy(text) {
var ok = false;
try {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
document.body.appendChild(ta);
ta.select();
ok = document.execCommand("copy");
document.body.removeChild(ta);
} catch (err) { ok = false; }
return ok;
}
el.copy.addEventListener("click", function () {
var text = el.eq.textContent.replace(/\s+/g, " ").trim();
if (!text || text === "—") return;
var done = false;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
done = true;
navigator.clipboard.writeText(text).then(function () {
flashCopied(true);
}, function () {
flashCopied(legacyCopy(text));
});
}
} catch (err) { done = false; }
if (!done) flashCopied(legacyCopy(text));
});
/* ---------- tooltip ---------- */
var pinned = false, hover = false, tipTimer = null;
function syncTip() {
var on = pinned || hover;
el.tip.hidden = !on;
el.tipBtn.setAttribute("aria-expanded", on ? "true" : "false");
}
function closeTip() {
if (tipTimer) { clearTimeout(tipTimer); tipTimer = null; }
pinned = false; hover = false; syncTip();
}
function openHover() {
if (tipTimer) { clearTimeout(tipTimer); tipTimer = null; }
hover = true; syncTip();
}
el.tipBtn.addEventListener("click", function () {
if (tipTimer) { clearTimeout(tipTimer); tipTimer = null; }
pinned = !pinned;
if (!pinned) hover = false; /* clicking to close really closes, hover or not */
syncTip();
});
el.tipwrap.addEventListener("mouseenter", openHover);
el.tipwrap.addEventListener("focusin", openHover);
el.tipwrap.addEventListener("mouseleave", function () {
/* the panel sits below the note, so the pointer has a gap to cross to reach it */
if (tipTimer) clearTimeout(tipTimer);
tipTimer = setTimeout(function () { tipTimer = null; hover = false; syncTip(); }, 260);
});
el.tipwrap.addEventListener("focusout", function () { hover = false; syncTip(); });
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !el.tip.hidden) { closeTip(); el.tipBtn.focus(); }
});
document.addEventListener("click", function (e) {
if (!el.tipwrap.contains(e.target) && (pinned || hover)) closeTip();
});
/* ---------- platform theme ---------- */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* ---------- boot ---------- */
el.specUnits.textContent = totalUnits + " units";
setCat("length");
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.