Compare: diverging comfort bars, baseline strip, real loading state (#68)

Rework the compare results so the winner, the cold/warm skew, and the
places' baselines all read at a glance, and make loading obvious.

- Diverging comfort bar per place: colder days grow left of a fixed
  centre comfort marker, warmer days grow right, both on one 0–100%
  scale — a cold-skewed place and a hot-skewed one mirror each other and
  compare directly. Replaces the left-anchored stacked bar + 3-up stat
  grid (the grid was the main mobile-clutter source).
- Baseline strip above the cards: every place's average basis temp on
  one shared °F axis with the comfort range shaded, plus a middle-80%
  (p10–p90) spread, so baselines line up side by side. computeStats now
  also returns avgTemp/p10/p90.
- Stronger winner: filled accent rank badge, accent card frame, and a
  "Best match" flag on the subline.
- Real in-progress state: a CSS spinner ring on loading chips, a queued
  dot on pending chips, an inline spinner in the header, and shimmering
  skeleton cards for still-loading places (previously only muted text —
  the referenced chip spinner never existed).
- Mobile: tighter baseline gutter/axis and caption sizing.
This commit is contained in:
Emi Griffith 2026-07-11 16:49:00 -07:00 committed by GitHub
parent 2662fa38e5
commit b49489e730
3 changed files with 181 additions and 44 deletions

View file

@ -97,6 +97,10 @@
often it runs colder or warmer, and by how much.</p> often it runs colder or warmer, and by how much.</p>
</div> </div>
<!-- Baseline: every place's typical temperature on one shared axis, so their
baselines line up against each other and the shaded comfort range. -->
<section id="cmp-baseline" class="cmp-baseline" hidden></section>
<div id="cmp-results" class="cmp-results"></div> <div id="cmp-results" class="cmp-results"></div>
<!-- Climate distribution, independent of the comfort filter: how each place's <!-- Climate distribution, independent of the comfort filter: how each place's

View file

@ -127,6 +127,7 @@ const locList = document.getElementById("cmp-loc-list");
const params = document.getElementById("cmp-params"); const params = document.getElementById("cmp-params");
const placeholder = document.getElementById("cmp-placeholder"); const placeholder = document.getElementById("cmp-placeholder");
const head = document.getElementById("cmp-head"); const head = document.getElementById("cmp-head");
const baseline = document.getElementById("cmp-baseline");
const results = document.getElementById("cmp-results"); const results = document.getElementById("cmp-results");
const loInput = document.getElementById("cmp-lo"); const loInput = document.getElementById("cmp-lo");
const hiInput = document.getElementById("cmp-hi"); const hiInput = document.getElementById("cmp-hi");
@ -249,24 +250,37 @@ function basisTemp(r) {
return hi != null && lo != null ? (hi + lo) / 2 : null; // mean return hi != null && lo != null ? (hi + lo) / 2 : null; // mean
} }
// A quantile (01) of an ascending-sorted array, linearly interpolated.
function quantile(sorted, q) {
if (!sorted.length) return null;
const i = (sorted.length - 1) * q, lo = Math.floor(i), hi = Math.ceil(i);
return lo === hi ? sorted[lo] : sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
}
function computeStats(series) { function computeStats(series) {
let n = 0, below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumMiss = 0; let below = 0, above = 0, comf = 0, sumBelow = 0, sumAbove = 0, sumMiss = 0, sumTemp = 0;
const temps = []; // every valid basis temp (°F), for the average + spread
for (const r of series) { for (const r of series) {
const t = basisTemp(r); const t = basisTemp(r);
if (t == null || isNaN(t)) continue; if (t == null || isNaN(t)) continue;
n++; temps.push(t);
sumTemp += t;
// Distance outside the comfort range (0 when inside) drives the "typical miss". // Distance outside the comfort range (0 when inside) drives the "typical miss".
if (t < lo) { below++; const m = lo - t; sumBelow += m; sumMiss += m; } if (t < lo) { below++; const m = lo - t; sumBelow += m; sumMiss += m; }
else if (t > hi) { above++; const m = t - hi; sumAbove += m; sumMiss += m; } else if (t > hi) { above++; const m = t - hi; sumAbove += m; sumMiss += m; }
else comf++; else comf++;
} }
const n = temps.length;
if (!n) return null; if (!n) return null;
temps.sort((a, b) => a - b);
return { return {
n, comf, below, above, n, comf, below, above,
comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n, comfortPct: 100 * comf / n, belowPct: 100 * below / n, abovePct: 100 * above / n,
avgBelow: below ? sumBelow / below : 0, avgBelow: below ? sumBelow / below : 0,
avgAbove: above ? sumAbove / above : 0, avgAbove: above ? sumAbove / above : 0,
mad: sumMiss / n, mad: sumMiss / n,
// Baseline strip: the typical temperature (°F) and the middle-80% spread.
avgTemp: sumTemp / n, p10: quantile(temps, 0.1), p90: quantile(temps, 0.9),
}; };
} }
@ -277,42 +291,86 @@ const deg = (v) => fmtDelta(v);
function renderLocList() { function renderLocList() {
locList.innerHTML = locations.map((l, i) => { locList.innerHTML = locations.map((l, i) => {
let label, cls = "cmp-chip"; let label, cls = "cmp-chip", lead = "";
// Show the raw coordinates while pending/loading (with the loading class still // Coordinates show until the place name resolves. A spinning ring marks a live
// driving the spinner); once scored, l.name holds the resolved neighborhood + // load; a "queued" dot marks a location waiting for the next Refresh tap. Once
// city, so the coordinates are replaced live by the place name. // scored, l.name holds the resolved neighborhood + city.
const coords = `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`; const coords = `${l.lat.toFixed(2)}, ${l.lon.toFixed(2)}`;
if (l.loading) { label = l.name || coords; cls += " loading"; } if (l.loading) { label = l.name || coords; cls += " loading"; lead = `<span class="cmp-spinner cmp-spinner-chip" aria-hidden="true"></span>`; }
else if (l.error) { label = "Couldn't load"; cls += " error"; } else if (l.error) { label = "Couldn't load"; cls += " error"; }
else if (!l.series) { label = l.name || coords; cls += " pending"; } else if (!l.series) { label = l.name || coords; cls += " pending"; lead = `<span class="cmp-chip-dot" aria-hidden="true"></span>`; }
else { label = l.name; } else { label = l.name; }
return `<li class="${cls}"><span class="cmp-chip-name">${label}</span>` + return `<li class="${cls}">${lead}<span class="cmp-chip-name">${label}</span>` +
`<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">&times;</button></li>`; `<button type="button" class="cmp-chip-x" data-i="${i}" aria-label="Remove ${label}">&times;</button></li>`;
}).join(""); }).join("");
} }
// A ranked place: the comfort share up top, then a diverging bar whose colder days
// grow left of a fixed center (the comfort zone) and warmer days grow right — so a
// place that runs cold vs one that runs hot read as mirror images on a shared scale.
function rankCard(loc, s, rank) { function rankCard(loc, s, rank) {
const bar = `<div class="cmp-bar" role="img" aria-label="${pct(s.belowPct)} colder, ${pct(s.comfortPct)} in comfort, ${pct(s.abovePct)} warmer">` + const win = rank === 1;
`<span class="cmp-seg cmp-below" style="flex-basis:${s.belowPct}%"></span>` + const bar = `<div class="cmp-dv" role="img" aria-label="${pct(s.belowPct)} colder, ${pct(s.comfortPct)} in comfort, ${pct(s.abovePct)} warmer">` +
`<span class="cmp-seg cmp-comfort" style="flex-basis:${s.comfortPct}%"></span>` + `<div class="cmp-dv-side cmp-dv-left"><span class="cmp-dv-fill cmp-below" style="width:${s.belowPct}%"></span></div>` +
`<span class="cmp-seg cmp-above" style="flex-basis:${s.abovePct}%"></span></div>`; `<span class="cmp-dv-mid" aria-hidden="true"></span>` +
const stats = `<div class="cmp-dv-side cmp-dv-right"><span class="cmp-dv-fill cmp-above" style="width:${s.abovePct}%"></span></div></div>`;
`<div class="cmp-stats">` + const cap =
`<div class="cmp-stat cmp-s-below"><span class="cmp-k">Colder</span><span class="cmp-v">${pct(s.belowPct)}</span><span class="cmp-d">${s.below ? `avg ${deg(s.avgBelow)} below` : "—"}</span></div>` + `<div class="cmp-dv-cap">` +
`<div class="cmp-stat cmp-s-comfort"><span class="cmp-k">In comfort</span><span class="cmp-v">${pct(s.comfortPct)}</span><span class="cmp-d">${fmtTemp(lo)}${fmtTemp(hi)}</span></div>` + `<span class="cmp-cap cmp-cap-below">${pct(s.belowPct)} colder${s.below ? ` · avg ${deg(s.avgBelow)} under` : ""}</span>` +
`<div class="cmp-stat cmp-s-above"><span class="cmp-k">Warmer</span><span class="cmp-v">${pct(s.abovePct)}</span><span class="cmp-d">${s.above ? `avg ${deg(s.avgAbove)} above` : "—"}</span></div>` + `<span class="cmp-cap cmp-cap-above">${s.above ? `avg ${deg(s.avgAbove)} over · ` : ""}${pct(s.abovePct)} warmer</span>` +
`</div>`; `</div>`;
return `<div class="cmp-card">` + const sub = win ? `<span class="cmp-best">★ Best match</span> · ${s.n} days` : `${s.n} days`;
return `<div class="cmp-card${win ? " cmp-win" : ""}">` +
`<div class="cmp-card-top">` + `<div class="cmp-card-top">` +
`<span class="cmp-rank">#${rank}</span>` + `<span class="cmp-rank">${rank}</span>` +
`<div class="cmp-card-name"><span class="cmp-name">${loc.name}</span><span class="cmp-daycount">${s.n} days</span></div>` + `<div class="cmp-card-name"><span class="cmp-name">${loc.name}</span><span class="cmp-daycount">${sub}</span></div>` +
`<div class="cmp-big"><b>${pct(s.comfortPct)}</b><span>in comfort</span></div>` + `<div class="cmp-big"><b>${pct(s.comfortPct)}</b><span>in comfort</span></div>` +
`</div>` + `</div>` +
bar + stats + bar + cap +
`<div class="cmp-mad">Typical day misses comfort by <b>${deg(s.mad)}</b></div>` + `<div class="cmp-mad">Typical day misses comfort by <b>${deg(s.mad)}</b></div>` +
`</div>`; `</div>`;
} }
// A placeholder card shown while a location's data is still streaming in, so the
// results don't jump when it resolves. Animated by CSS (.cmp-skel).
const skeletonRow = () => `<div class="cmp-card cmp-skel" aria-hidden="true">` +
`<div class="cmp-skel-top"><span class="cmp-skel-badge"></span>` +
`<span class="cmp-skel-name"></span><span class="cmp-skel-big"></span></div>` +
`<div class="cmp-skel-bar"></div></div>`;
// Baseline strip: every place's typical basis temperature on one shared °F axis,
// with the comfort range shaded, so the places' baselines line up for direct
// comparison. Each row shows the average (dot) and the middle-80% spread (bar),
// colored by where the average sits relative to the comfort range.
function renderBaseline(scored) {
if (!scored.length) { baseline.hidden = true; baseline.innerHTML = ""; return; }
let min = lo, max = hi;
for (const { s } of scored) { min = Math.min(min, s.p10); max = Math.max(max, s.p90); }
const pad = Math.max(3, (max - min) * 0.06); // keep markers off the edges
min -= pad; max += pad;
const span = max - min || 1;
const posF = (v) => clamp((v - min) / span * 100, 0, 100);
const bandL = posF(lo), bandR = posF(hi);
const posClass = (t) => (t < lo ? "cmp-below" : t > hi ? "cmp-above" : "cmp-comfort");
const rows = scored.map(({ loc, s }) => {
const cls = posClass(s.avgTemp), a = posF(s.avgTemp);
return `<div class="cmp-bl-row">` +
`<span class="cmp-bl-name">${loc.name}</span>` +
`<div class="cmp-bl-track" role="img" aria-label="${loc.name}: average ${fmtTemp(s.avgTemp)}, typical ${fmtTemp(s.p10)} to ${fmtTemp(s.p90)}">` +
`<span class="cmp-bl-band" style="left:${bandL}%;right:${100 - bandR}%"></span>` +
`<span class="cmp-bl-spread ${cls}" style="left:${posF(s.p10)}%;right:${100 - posF(s.p90)}%"></span>` +
`<span class="cmp-bl-dot ${cls}" style="left:${a}%"></span>` +
`<span class="cmp-bl-val" style="left:${a}%">${fmtTemp(s.avgTemp)}</span>` +
`</div></div>`;
}).join("");
baseline.hidden = false;
baseline.innerHTML =
`<div class="cmp-bl-head">Typical temperature by place ` +
`<span class="hint">dot = average · bar = middle-80% of days · shaded = your comfort range</span></div>` +
`<div class="cmp-bl-rows">${rows}</div>` +
`<div class="cmp-bl-axis"><span>${fmtTemp(min)}</span><span>${fmtTemp((min + max) / 2)}</span><span>${fmtTemp(max)}</span></div>`;
}
function renderResults() { function renderResults() {
const loading = locations.filter((l) => l.loading).length; const loading = locations.filter((l) => l.loading).length;
const scored = locations const scored = locations
@ -321,9 +379,14 @@ function renderResults() {
// Best comfort share first; break ties by the smaller typical miss. // Best comfort share first; break ties by the smaller typical miss.
scored.sort((a, b) => (b.s.comfortPct - a.s.comfortPct) || (a.s.mad - b.s.mad)); scored.sort((a, b) => (b.s.comfortPct - a.s.comfortPct) || (a.s.mad - b.s.mad));
renderBaseline(scored);
if (!scored.length) { if (!scored.length) {
head.hidden = true; head.hidden = true;
results.innerHTML = loading ? `<p class="spinner">Loading daily weather…</p>` : ""; results.innerHTML = loading
? `<p class="cmp-loading"><span class="cmp-spinner"></span>Loading daily weather…</p>` +
Array.from({ length: Math.min(loading, 3) }, skeletonRow).join("")
: "";
return; return;
} }
@ -335,9 +398,11 @@ function renderResults() {
: `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days land in ${fmtTemp(lo)}${fmtTemp(hi)}`; : `<b>${win.loc.name}</b>: ${pct(win.s.comfortPct)} of days land in ${fmtTemp(lo)}${fmtTemp(hi)}`;
head.hidden = false; head.hidden = false;
head.innerHTML = `<h2>${lead}</h2>` + head.innerHTML = `<h2>${lead}</h2>` +
`<p class="meta">By ${BASIS_LABEL[basis]} · ${span}${loading ? ` · loading ${loading} more…` : ""}</p>`; `<p class="meta">By ${BASIS_LABEL[basis]} · ${span}` +
`${loading ? ` · <span class="cmp-loading-inline"><span class="cmp-spinner"></span>loading ${loading} more…</span>` : ""}</p>`;
results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join(""); results.innerHTML = scored.map((x, i) => rankCard(x.loc, x.s, i + 1)).join("") +
(loading ? Array.from({ length: loading }, skeletonRow).join("") : "");
} }
// ---- distribution (independent of the comfort filter) ---- // ---- distribution (independent of the comfort filter) ----

View file

@ -825,10 +825,13 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
/* Ranked result cards. */ /* Ranked result cards. */
.cmp-results { display: grid; gap: 14px; } .cmp-results { display: grid; gap: 14px; }
.cmp-card { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 16px 18px; } .cmp-card { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 16px 18px; }
.cmp-card:first-child { border-color: var(--accent); }
.cmp-card-top { display: flex; align-items: center; gap: 12px; } .cmp-card-top { display: flex; align-items: center; gap: 12px; }
.cmp-rank { font-size: 15px; font-weight: 700; color: var(--muted); flex: 0 0 auto; } /* Rank badge: a filled disc — accent for the winner, muted for the rest. */
.cmp-card:first-child .cmp-rank { color: var(--accent); } .cmp-rank {
flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%;
display: grid; place-items: center; font-size: 13px; font-weight: 800;
background: var(--surface-2); color: var(--muted);
}
.cmp-card-name { flex: 1 1 auto; min-width: 0; } .cmp-card-name { flex: 1 1 auto; min-width: 0; }
.cmp-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; } .cmp-name { display: block; font-size: 16px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
.cmp-daycount { font-size: 12px; color: var(--muted); } .cmp-daycount { font-size: 12px; color: var(--muted); }
@ -836,25 +839,84 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.cmp-big b { display: block; font-size: 22px; color: var(--normal); line-height: 1.1; } .cmp-big b { display: block; font-size: 22px; color: var(--normal); line-height: 1.1; }
.cmp-big span { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } .cmp-big span { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.cmp-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; margin: 12px 0 10px; background: var(--surface-2); } /* Winner: accent frame + badge, and a "Best match" flag on the subline. */
.cmp-seg { display: block; height: 100%; } .cmp-win { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
/* Scoped to the bar segments .cmp-comfort is also the comfort-slider param's .cmp-win .cmp-rank { background: var(--accent); color: #1a1206; }
class, which must not inherit the green fill. */ .cmp-best { color: var(--accent); font-weight: 700; }
.cmp-seg.cmp-below { background: var(--cold); }
.cmp-seg.cmp-comfort { background: var(--normal); }
.cmp-seg.cmp-above { background: var(--hot); }
.cmp-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } /* Diverging comfort bar: a fixed center marks the comfort zone; colder days grow
.cmp-stat { text-align: center; padding: 8px 4px; border-radius: 8px; background: var(--surface-2); } left of it, warmer days grow right. Both halves share one 0100% scale, so a
.cmp-k { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; } cold-skewed place and a hot-skewed one mirror each other and compare at a glance. */
.cmp-v { display: block; font-size: 18px; font-weight: 700; } .cmp-dv { display: flex; align-items: stretch; height: 16px; margin: 14px 0 8px; }
.cmp-d { display: block; font-size: 11.5px; color: var(--muted); } .cmp-dv-side { flex: 1 1 0; position: relative; background: var(--surface-2); overflow: hidden; }
.cmp-s-below .cmp-v { color: var(--cold); } .cmp-dv-left { border-radius: 8px 0 0 8px; }
.cmp-s-comfort .cmp-v { color: var(--normal); } .cmp-dv-right { border-radius: 0 8px 8px 0; }
.cmp-s-above .cmp-v { color: var(--hot); } .cmp-dv-fill { position: absolute; top: 0; bottom: 0; }
.cmp-mad { margin: 12px 0 0; font-size: 13px; color: var(--muted); } .cmp-dv-left .cmp-dv-fill { right: 0; } /* colder grows leftward from center */
.cmp-dv-right .cmp-dv-fill { left: 0; } /* warmer grows rightward from center */
.cmp-dv-fill.cmp-below { background: var(--cold); }
.cmp-dv-fill.cmp-above { background: var(--hot); }
.cmp-dv-mid { flex: 0 0 4px; background: var(--normal); } /* the comfort-zone marker */
.cmp-dv-cap { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; }
.cmp-cap-below { color: var(--cold); }
.cmp-cap-above { color: var(--hot); text-align: right; }
.cmp-mad { margin: 10px 0 0; font-size: 13px; color: var(--muted); }
.cmp-mad b { color: var(--text); } .cmp-mad b { color: var(--text); }
/* Baseline strip: every place's typical basis temperature on one shared °F axis,
with the comfort range shaded, so the places' baselines line up side by side. */
.cmp-baseline { margin: 0 0 18px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
.cmp-baseline[hidden] { display: none; }
.cmp-bl-head { font-size: 14px; font-weight: 700; }
.cmp-bl-head .hint { display: block; font-weight: 400; font-size: 11.5px; color: var(--muted); margin: 2px 0 12px; max-width: none; }
.cmp-bl-rows { display: grid; gap: 10px; }
.cmp-bl-row { display: grid; grid-template-columns: 120px 1fr; align-items: center; gap: 12px; }
.cmp-bl-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmp-bl-track { position: relative; height: 26px; }
.cmp-bl-band {
position: absolute; top: 0; bottom: 0; border-radius: 3px;
background: color-mix(in srgb, var(--normal) 20%, transparent);
outline: 1px solid color-mix(in srgb, var(--normal) 50%, transparent); outline-offset: -1px;
}
.cmp-bl-spread { position: absolute; top: 17px; height: 4px; transform: translateY(-50%); border-radius: 2px; opacity: .6; }
.cmp-bl-dot { position: absolute; top: 17px; width: 12px; height: 12px; border-radius: 50%; transform: translate(-50%, -50%); border: 2px solid var(--surface); }
.cmp-bl-val { position: absolute; top: 1px; transform: translateX(-50%); font-size: 10.5px; font-weight: 700; color: var(--text); white-space: nowrap; }
.cmp-bl-spread.cmp-below, .cmp-bl-dot.cmp-below { background: var(--cold); }
.cmp-bl-spread.cmp-comfort, .cmp-bl-dot.cmp-comfort { background: var(--normal); }
.cmp-bl-spread.cmp-above, .cmp-bl-dot.cmp-above { background: var(--hot); }
.cmp-bl-axis { display: flex; justify-content: space-between; margin: 8px 0 0 132px; font-size: 11px; color: var(--muted); }
/* Loading affordances: a spinning ring (chips + header) and shimmering skeleton
cards, so a still-loading place is obvious and the results don't pop in cold. */
@keyframes cmp-spin { to { transform: rotate(360deg); } }
@keyframes cmp-shimmer { 100% { background-position: -200% 0; } }
.cmp-spinner {
display: inline-block; width: 14px; height: 14px; border-radius: 50%; box-sizing: border-box;
border: 2px solid var(--border); border-top-color: var(--accent);
animation: cmp-spin .7s linear infinite; vertical-align: -2px;
}
.cmp-spinner-chip { width: 12px; height: 12px; }
.cmp-chip-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); opacity: .55; }
.cmp-loading { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; color: var(--muted); font-size: 14px; }
.cmp-loading-inline { display: inline-flex; align-items: center; gap: 6px; }
.cmp-loading-inline .cmp-spinner { width: 12px; height: 12px; }
.cmp-skel-top { display: flex; align-items: center; gap: 12px; }
.cmp-skel-badge, .cmp-skel-name, .cmp-skel-big, .cmp-skel-bar {
display: block; border-radius: 6px;
background: linear-gradient(90deg, var(--surface-2) 25%, var(--border) 37%, var(--surface-2) 63%);
background-size: 200% 100%; animation: cmp-shimmer 1.3s ease-in-out infinite;
}
.cmp-skel-badge { width: 26px; height: 26px; border-radius: 50%; flex: 0 0 auto; }
.cmp-skel-name { flex: 1 1 auto; height: 16px; }
.cmp-skel-big { width: 56px; height: 22px; flex: 0 0 auto; }
.cmp-skel-bar { height: 16px; margin-top: 16px; }
@media (prefers-reduced-motion: reduce) {
.cmp-spinner { animation-duration: 1.4s; }
.cmp-skel-badge, .cmp-skel-name, .cmp-skel-big, .cmp-skel-bar { animation: none; }
}
/* Dual-handle comfort slicer: two range inputs overlaid on a shared track, with a /* Dual-handle comfort slicer: two range inputs overlaid on a shared track, with a
fill marking the selected span. Only the thumbs take pointer events, so both fill marking the selected span. Only the thumbs take pointer events, so both
handles stay grabbable even where the inputs overlap. */ handles stay grabbable even where the inputs overlap. */
@ -980,4 +1042,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 60px; }
.cmp-params { grid-template-columns: 1fr; padding: 14px; } .cmp-params { grid-template-columns: 1fr; padding: 14px; }
.cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; } .cmp-basis-block .metric-toggle button { flex: 1 0 22%; padding: 10px 4px; }
.cmp-big b { font-size: 20px; } .cmp-big b { font-size: 20px; }
/* Baseline strip: tighten the name gutter and shrink text so the shared axis
still fits a phone; keep the axis ticks aligned to the narrower track. */
.cmp-bl-row { grid-template-columns: 84px 1fr; gap: 8px; }
.cmp-bl-name { font-size: 12px; }
.cmp-bl-axis { margin-left: 92px; }
.cmp-dv-cap { font-size: 11px; }
} }