eleven / messenger Public

status: metric/imperial toggle on the carbon tally page (#741)

A labelled Metric | Imperial button group beside "Estimated so far"
converts the whole page — tiles, the car/petrol equivalents line, the
chart (axis ticks recomputed in display units so they stay clean,
end-labels, tooltip, aria labels), and the ledger. kg→lb, km→miles,
litres→US gallons. The JSON stays canonical metric; conversion is
purely presentational. Default follows the reader's locale (US
customary regions get imperial), the choice is remembered per browser,
and ?units=metric|imperial makes a mode linkable. Selected state reads
by weight + underline, not color alone.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 296685dc201985a30ef70ca8f798c233b2aba9df parent 845bd91
1 file changed, +100 −28
  • web/status/carbon.html +100 −28
diff --git a/web/status/carbon.html b/web/status/carbon.html
index 6f78b35..e9e46b4 100644
--- a/web/status/carbon.html
+++ b/web/status/carbon.html
@@ -2,7 +2,9 @@
Fully self-contained: inline CSS/JS, no external requests except
./carbon.json (relative). Unlike fleet/books, the JSON is COMMITTED data
(web/status/carbon.json, accrued by tools/carbon-tally.mjs), republished
- as-is by deploy/status-publish.sh — aggregate token counts and kg only. -->
+ as-is by deploy/status-publish.sh — aggregate token counts and kg only.
+ The JSON stays metric (canonical); the metric/imperial toggle is purely
+ presentational (?units=imperial|metric, remembered per browser). -->
<!doctype html>
<html lang="en">
<head>
@@ -49,6 +51,22 @@
.lede { color: var(--ink-2); margin: 6px 0 2px; }
.asof { font-weight: 600; margin: 10px 0 24px; }
+ .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin: 0 0 10px; }
+ .row h2 { margin: 0; }
+ /* Metric/imperial: a labelled two-button group; the selected unit reads by
+ weight + an underline mark, never color alone. */
+ .units { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
+ .units button {
+ font: inherit; font-size: 13px; padding: 4px 12px; border: 0; cursor: pointer;
+ background: var(--surface); color: var(--ink-2);
+ }
+ .units button + button { border-left: 1px solid var(--border); }
+ .units button[aria-pressed="true"] {
+ color: var(--ink); font-weight: 650;
+ box-shadow: inset 0 -2px 0 var(--series);
+ }
+ .units button:focus-visible { outline: 2px solid var(--series); outline-offset: -2px; }
+
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 12px; }
.tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
.tile .label { font-size: 13px; color: var(--ink-2); }
@@ -73,7 +91,7 @@
#tip .sub { color: var(--ink-2); }
.pt:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 50%; }
- /* Measurement ledger: single-series horizontal bars (kg, central estimate);
+ /* Measurement ledger: single-series horizontal bars (central estimate);
rows are real text, bars aria-hidden — same mark spec as books.html. */
.bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.bars li { display: grid; grid-template-columns: minmax(120px, 200px) 1fr 5.5em; align-items: center; gap: 12px; padding: 10px 16px; }
@@ -114,7 +132,13 @@
<div id="tally">
<section aria-labelledby="h-total">
- <h2 id="h-total">Estimated so far</h2>
+ <div class="row">
+ <h2 id="h-total">Estimated so far</h2>
+ <div class="units" role="group" aria-label="Measurement units">
+ <button type="button" id="u-metric" aria-pressed="false">Metric</button>
+ <button type="button" id="u-imperial" aria-pressed="false">Imperial</button>
+ </div>
+ </div>
<div class="tiles">
<div class="tile"><div class="label">Central estimate</div><div class="value" id="t-central">—</div></div>
<div class="tile"><div class="label">Honest range</div><div class="value" id="t-range">—</div></div>
@@ -128,13 +152,14 @@
<figure>
<svg id="chart" viewBox="0 0 720 240" role="img" aria-label=""></svg>
<div id="tip"></div>
- <figcaption>Cumulative kg CO₂e — the line is the central estimate, the
- wash is the low–high band. Exact figures are in the ledger below.</figcaption>
+ <figcaption id="chart-cap">Cumulative CO₂e — the line is the central
+ estimate, the wash is the low–high band. Exact figures are in the
+ ledger below.</figcaption>
</figure>
</section>
<section aria-labelledby="h-entries">
- <h2 id="h-entries">The ledger (central kg per period)</h2>
+ <h2 id="h-entries">The ledger (central estimate per period)</h2>
<ul class="bars" id="entries"><li><span class="period">Loading…</span></li></ul>
<p class="equiv" id="notcounted"></p>
</section>
@@ -173,9 +198,38 @@ const NS = "http://www.w3.org/2000/svg";
// ~2 g CO₂e per member per year.
const CAR_G_PER_KM = 170, PETROL_KG_PER_L = 2.3, FLEET_G_PER_MEMBER_YEAR = 2;
+// The JSON is canonical metric; imperial is a display conversion only.
+const UNITS = {
+ metric: { mass: "kg", massF: 1, dist: "km", distF: 1, vol: "litres", volF: 1 },
+ imperial: { mass: "lb", massF: 2.20462, dist: "miles", distF: 0.621371, vol: "US gallons", volF: 0.264172 },
+};
+// Resolution: ?units= wins (and is remembered), then the saved choice, then
+// the reader's locale (US customary regions default imperial).
+function initialUnits() {
+ const q = new URLSearchParams(location.search).get("units");
+ if (q === "metric" || q === "imperial") { try { localStorage.setItem("carbonUnits", q); } catch {} return q; }
+ try {
+ const saved = localStorage.getItem("carbonUnits");
+ if (saved === "metric" || saved === "imperial") return saved;
+ } catch {}
+ const region = (new Intl.Locale(navigator.language || "en").region || "").toUpperCase();
+ return ["US", "LR", "MM"].includes(region) ? "imperial" : "metric";
+}
+let units = initialUnits();
+const U = () => UNITS[units];
+// Whole numbers once they're big enough to not need the decimal.
+const nice = (v) => (Math.abs(v) >= 20 ? Math.round(v) : Math.round(v * 10) / 10);
+const mass = (kg) => nice(kg * U().massF);
+
+let lastData = null;
+
// All rendering is textContent / createElement — never innerHTML of fetched
// data, even though the feed is our own.
function render(d) {
+ lastData = d;
+ $("u-metric").setAttribute("aria-pressed", String(units === "metric"));
+ $("u-imperial").setAttribute("aria-pressed", String(units === "imperial"));
+
$("asof").dateTime = d.updated_at;
$("asof").textContent = new Date(d.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
@@ -183,17 +237,19 @@ function render(d) {
$("factors").textContent = "we band public calibration points at " + f.low + "–" + f.high +
" mg CO₂e per output token, central " + f.central + " mg";
- $("t-central").textContent = "~" + d.totals.kg_central + " kg CO₂e";
- $("t-range").textContent = d.totals.kg_low + "–" + d.totals.kg_high + " kg";
+ $("t-central").textContent = "~" + mass(d.totals.kg_central) + " " + U().mass + " CO₂e";
+ $("t-range").textContent = mass(d.totals.kg_low) + "–" + mass(d.totals.kg_high) + " " + U().mass;
$("t-tokens").textContent = compact.format(d.totals.output_tokens);
- const km = Math.round(d.totals.kg_central * 1000 / CAR_G_PER_KM);
- const litres = Math.round(d.totals.kg_central / PETROL_KG_PER_L);
+ const dist = nice(d.totals.kg_central * 1000 / CAR_G_PER_KM * U().distF);
+ const vol = nice(d.totals.kg_central / PETROL_KG_PER_L * U().volF);
const memberYears = Math.round(d.totals.kg_central * 1000 / FLEET_G_PER_MEMBER_YEAR);
- $("equiv").textContent = "Centrally, that’s like driving a petrol car about " + km +
- " km (burning ~" + litres + " litres of petrol) — or roughly " +
+ $("equiv").textContent = "Centrally, that’s like driving a petrol car about " + dist + " " + U().dist +
+ " (burning ~" + vol + " " + U().vol + " of petrol) — or roughly " +
memberYears.toLocaleString() + " member-years of actually running the service.";
+ $("chart-cap").textContent = "Cumulative " + U().mass + " CO₂e — the line is the central estimate, " +
+ "the wash is the low–high band. Exact figures are in the ledger below.";
chart(d);
const list = $("entries");
@@ -221,7 +277,7 @@ function render(d) {
track.append(bar);
const kg = document.createElement("span");
kg.className = "kg";
- kg.textContent = e.kg_central + " kg";
+ kg.textContent = mass(e.kg_central) + " " + U().mass;
li.append(period, track, kg);
list.append(li);
}
@@ -231,10 +287,13 @@ function render(d) {
// Cumulative line over time: central line + low–high wash. Crosshair snaps to
// the nearest measurement; points are keyboard-focusable with the same tooltip.
+// Values are cumulated in canonical kg, converted to the display unit at the
+// last moment so labels, ticks, and tooltip always agree.
function chart(d) {
const svg = $("chart");
svg.replaceChildren();
const round1 = (v) => Math.round(v * 10) / 10;
+ const disp = (kg) => nice(kg * U().massF);
// Cumulate in period_end order across ALL sources; the curve starts at the
// earliest period_start any source measured from.
const ordered = d.entries.slice().sort((a, b) => Date.parse(a.period_end) - Date.parse(b.period_end));
@@ -245,14 +304,16 @@ function chart(d) {
c = round1(c + e.kg_central); lo = round1(lo + e.kg_low); hi = round1(hi + e.kg_high);
pts.push({ t: Date.parse(e.period_end), c, lo, hi, e });
}
- const W = 720, H = 240, m = { l: 44, r: 86, t: 14, b: 26 };
+ const W = 720, H = 240, m = { l: 48, r: 86, t: 14, b: 26 };
const span = Math.max(1, pts[pts.length - 1].t - pts[0].t);
const x = (t) => m.l + (W - m.l - m.r) * (t - pts[0].t) / span;
- // Clean y ticks: pick the smallest 1/2/5×10ⁿ step that needs ≤ 4 intervals.
+ // Scale + ticks live in DISPLAY units so they stay clean after conversion.
+ const hiDisp = hi * U().massF;
let step = 1;
- for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]) { if (hi / s <= 4) { step = s; break; } }
- const yMax = Math.max(step, Math.ceil(hi / step) * step);
- const y = (v) => H - m.b - (H - m.b - m.t) * v / yMax;
+ for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]) { if (hiDisp / s <= 4) { step = s; break; } }
+ const yMax = Math.max(step, Math.ceil(hiDisp / step) * step);
+ const y = (kg) => H - m.b - (H - m.b - m.t) * (kg * U().massF) / yMax;
+ const yD = (v) => H - m.b - (H - m.b - m.t) * v / yMax;
const el = (name, attrs, text) => {
const n = document.createElementNS(NS, name);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
@@ -262,11 +323,12 @@ function chart(d) {
};
svg.setAttribute("aria-label", "Cumulative carbon estimate from " + day(pts[0].t) +
- " to " + day(pts[pts.length - 1].t) + ": central " + c + " kg CO₂e, range " + lo + " to " + hi + " kg.");
+ " to " + day(pts[pts.length - 1].t) + ": central " + disp(c) + " " + U().mass +
+ " CO₂e, range " + disp(lo) + " to " + disp(hi) + " " + U().mass + ".");
for (let v = 0; v <= yMax; v += step) {
- el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)", "stroke-width": 1 });
- el("text", { x: m.l - 8, y: y(v) + 4, "text-anchor": "end", "font-size": 12,
+ el("line", { x1: m.l, x2: W - m.r, y1: yD(v), y2: yD(v), stroke: "var(--grid)", "stroke-width": 1 });
+ el("text", { x: m.l - 8, y: yD(v) + 4, "text-anchor": "end", "font-size": 12,
fill: "var(--ink-2)", style: "font-variant-numeric: tabular-nums" }, String(v));
}
for (const p of pts) {
@@ -281,9 +343,9 @@ function chart(d) {
// Selective direct labels at the right edge; ticks + tooltip carry the rest.
const end = pts[pts.length - 1];
- el("text", { x: x(end.t) + 10, y: y(end.hi) + 4, "font-size": 12, fill: "var(--ink-2)" }, "high " + end.hi);
- el("text", { x: x(end.t) + 10, y: y(end.c) + 4, "font-size": 12, "font-weight": 650, fill: "var(--ink)" }, "~" + end.c + " kg");
- el("text", { x: x(end.t) + 10, y: y(end.lo) + 12, "font-size": 12, fill: "var(--ink-2)" }, "low " + end.lo);
+ el("text", { x: x(end.t) + 10, y: y(end.hi) + 4, "font-size": 12, fill: "var(--ink-2)" }, "high " + disp(end.hi));
+ el("text", { x: x(end.t) + 10, y: y(end.c) + 4, "font-size": 12, "font-weight": 650, fill: "var(--ink)" }, "~" + disp(end.c) + " " + U().mass);
+ el("text", { x: x(end.t) + 10, y: y(end.lo) + 12, "font-size": 12, fill: "var(--ink-2)" }, "low " + disp(end.lo));
const hair = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--grid)", "stroke-width": 1, visibility: "hidden" });
const dots = pts.map((p) => el("circle", { cx: x(p.t), cy: y(p.c), r: 4.5, fill: "var(--series)",
@@ -297,15 +359,15 @@ function chart(d) {
tip.replaceChildren();
const big = document.createElement("div");
big.className = "big";
- big.textContent = "~" + p.c + " kg CO₂e";
+ big.textContent = "~" + disp(p.c) + " " + U().mass + " CO₂e";
const when = document.createElement("div");
when.className = "sub";
- when.textContent = "by " + day(p.t) + " · range " + p.lo + "–" + p.hi + " kg";
+ when.textContent = "by " + day(p.t) + " · range " + disp(p.lo) + "–" + disp(p.hi) + " " + U().mass;
tip.append(big, when);
if (p.e) {
const delta = document.createElement("div");
delta.className = "sub";
- delta.textContent = "+" + p.e.kg_central + " kg (" + p.e.source + ", " + p.e.basis + ", " +
+ delta.textContent = "+" + disp(p.e.kg_central) + " " + U().mass + " (" + p.e.source + ", " + p.e.basis + ", " +
compact.format(p.e.output_tokens) + " output tokens)";
tip.append(delta);
}
@@ -337,12 +399,22 @@ function chart(d) {
dot.classList.add("pt");
dot.setAttribute("tabindex", "0");
dot.setAttribute("role", "img");
- dot.setAttribute("aria-label", "By " + day(p.t) + ": about " + p.c + " kg CO₂e, range " + p.lo + " to " + p.hi);
+ dot.setAttribute("aria-label", "By " + day(p.t) + ": about " + disp(p.c) + " " + U().mass +
+ " CO₂e, range " + disp(p.lo) + " to " + disp(p.hi));
dot.addEventListener("focus", () => show(p, dot));
dot.addEventListener("blur", hide);
});
}
+function setUnits(next) {
+ if (next === units) return;
+ units = next;
+ try { localStorage.setItem("carbonUnits", units); } catch {}
+ if (lastData) render(lastData);
+}
+$("u-metric").addEventListener("click", () => setUnits("metric"));
+$("u-imperial").addEventListener("click", () => setUnits("imperial"));
+
async function refresh() {
try {
const res = await fetch("./carbon.json", { cache: "no-cache" });