mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
UX Improvements
This commit is contained in:
parent
2c08206407
commit
05f14464b8
2 changed files with 161 additions and 38 deletions
|
|
@ -210,7 +210,7 @@ function computeDailyStoreSeriesFromReport(report, filter) {
|
||||||
|
|
||||||
if (minP !== null || maxP !== null) {
|
if (minP !== null || maxP !== null) {
|
||||||
const rp = rowPriceNum(r, stores);
|
const rp = rowPriceNum(r, stores);
|
||||||
// store-page behavior: "no price" rows pass the filter (they won't contribute anyway)
|
// "no price" rows pass the filter (they won't contribute anyway)
|
||||||
if (rp !== null) {
|
if (rp !== null) {
|
||||||
if (minP !== null && rp < minP) continue;
|
if (minP !== null && rp < minP) continue;
|
||||||
if (maxP !== null && rp > maxP) continue;
|
if (maxP !== null && rp > maxP) continue;
|
||||||
|
|
@ -271,17 +271,26 @@ async function loadCommonCommitsManifest() {
|
||||||
// Fallback: GitHub API commits for a path, collapsed to one commit per day (newest that day),
|
// Fallback: GitHub API commits for a path, collapsed to one commit per day (newest that day),
|
||||||
// returned oldest -> newest, same shape as manifest entries.
|
// returned oldest -> newest, same shape as manifest entries.
|
||||||
async function loadCommitsFallback({ owner, repo, branch, relPath }) {
|
async function loadCommitsFallback({ owner, repo, branch, relPath }) {
|
||||||
let apiCommits = await githubListCommits({ owner, repo, branch, path: relPath });
|
let apiCommits = await githubListCommits({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
branch,
|
||||||
|
path: relPath,
|
||||||
|
});
|
||||||
apiCommits = Array.isArray(apiCommits) ? apiCommits : [];
|
apiCommits = Array.isArray(apiCommits) ? apiCommits : [];
|
||||||
|
|
||||||
|
// newest -> oldest from API; we want newest-per-day then oldest -> newest
|
||||||
const byDate = new Map();
|
const byDate = new Map();
|
||||||
for (const c of apiCommits) {
|
for (const c of apiCommits) {
|
||||||
const sha = String(c?.sha || "");
|
const sha = String(c?.sha || "");
|
||||||
const ts = String(c?.commit?.committer?.date || c?.commit?.author?.date || "");
|
const ts = String(
|
||||||
|
c?.commit?.committer?.date || c?.commit?.author?.date || ""
|
||||||
|
);
|
||||||
const d = dateOnly(ts);
|
const d = dateOnly(ts);
|
||||||
if (!sha || !d) continue;
|
if (!sha || !d) continue;
|
||||||
if (!byDate.has(d)) byDate.set(d, { sha, date: d, ts });
|
if (!byDate.has(d)) byDate.set(d, { sha, date: d, ts });
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...byDate.values()].reverse();
|
return [...byDate.values()].reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,8 +306,10 @@ async function loadRawSeries({ group, size, onStatus }) {
|
||||||
const branch = "data";
|
const branch = "data";
|
||||||
|
|
||||||
const manifest = await loadCommonCommitsManifest();
|
const manifest = await loadCommonCommitsManifest();
|
||||||
|
|
||||||
let commits = Array.isArray(manifest?.files?.[rel]) ? manifest.files[rel] : null;
|
let commits = Array.isArray(manifest?.files?.[rel]) ? manifest.files[rel] : null;
|
||||||
|
|
||||||
|
// Fallback if manifest missing/empty
|
||||||
if (!commits || !commits.length) {
|
if (!commits || !commits.length) {
|
||||||
if (typeof onStatus === "function")
|
if (typeof onStatus === "function")
|
||||||
onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`);
|
onStatus(`Commits manifest missing for ${rel}; using GitHub API fallback…`);
|
||||||
|
|
@ -313,7 +324,11 @@ async function loadRawSeries({ group, size, onStatus }) {
|
||||||
|
|
||||||
const cacheKey = `${group}:${size}`;
|
const cacheKey = `${group}:${size}`;
|
||||||
const cached = RAW_SERIES_CACHE.get(cacheKey);
|
const cached = RAW_SERIES_CACHE.get(cacheKey);
|
||||||
if (cached && cached.latestSha === latestSha && cached.labels?.length === commits.length) {
|
if (
|
||||||
|
cached &&
|
||||||
|
cached.latestSha === latestSha &&
|
||||||
|
cached.labels?.length === commits.length
|
||||||
|
) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,12 +343,14 @@ async function loadRawSeries({ group, size, onStatus }) {
|
||||||
const stores = Array.isArray(newestReport?.stores)
|
const stores = Array.isArray(newestReport?.stores)
|
||||||
? newestReport.stores.map(String)
|
? newestReport.stores.map(String)
|
||||||
: [];
|
: [];
|
||||||
if (!stores.length) throw new Error(`No stores found in ${rel} at ${latestSha.slice(0, 7)}`);
|
if (!stores.length)
|
||||||
|
throw new Error(`No stores found in ${rel} at ${latestSha.slice(0, 7)}`);
|
||||||
|
|
||||||
const labels = commits.map((c) => String(c.date || "")).filter(Boolean);
|
const labels = commits.map((c) => String(c.date || "")).filter(Boolean);
|
||||||
const shaByIdx = commits.map((c) => String(c.sha || ""));
|
const shaByIdx = commits.map((c) => String(c.sha || ""));
|
||||||
|
|
||||||
if (typeof onStatus === "function") onStatus(`Loading ${labels.length} day(s)…`);
|
if (typeof onStatus === "function")
|
||||||
|
onStatus(`Loading ${labels.length} day(s)…`);
|
||||||
|
|
||||||
const reportsByIdx = new Array(shaByIdx.length).fill(null);
|
const reportsByIdx = new Array(shaByIdx.length).fill(null);
|
||||||
|
|
||||||
|
|
@ -342,12 +359,20 @@ async function loadRawSeries({ group, size, onStatus }) {
|
||||||
shaByIdx.map((sha, idx) =>
|
shaByIdx.map((sha, idx) =>
|
||||||
limitNet(async () => {
|
limitNet(async () => {
|
||||||
try {
|
try {
|
||||||
reportsByIdx[idx] = await githubFetchFileAtSha({ owner, repo, sha, path: rel });
|
reportsByIdx[idx] = await githubFetchFileAtSha({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
sha,
|
||||||
|
path: rel,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
reportsByIdx[idx] = null;
|
reportsByIdx[idx] = null;
|
||||||
} finally {
|
} finally {
|
||||||
done++;
|
done++;
|
||||||
if (typeof onStatus === "function" && (done % 10 === 0 || done === shaByIdx.length)) {
|
if (
|
||||||
|
typeof onStatus === "function" &&
|
||||||
|
(done % 10 === 0 || done === shaByIdx.length)
|
||||||
|
) {
|
||||||
onStatus(`Loading ${labels.length} day(s)… ${done}/${labels.length}`);
|
onStatus(`Loading ${labels.length} day(s)… ${done}/${labels.length}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -409,6 +434,28 @@ function computeSeriesFromRaw(raw, filter) {
|
||||||
return { labels, stores, seriesByStore, newestUsed, newestTotal };
|
return { labels, stores, seriesByStore, newestUsed, newestTotal };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- y-axis bounds ---------------- */
|
||||||
|
|
||||||
|
function computeYBounds(seriesByStore, defaultAbs) {
|
||||||
|
let mn = Infinity;
|
||||||
|
let mx = -Infinity;
|
||||||
|
|
||||||
|
for (const arr of Object.values(seriesByStore || {})) {
|
||||||
|
if (!Array.isArray(arr)) continue;
|
||||||
|
for (const v of arr) {
|
||||||
|
if (!Number.isFinite(v)) continue;
|
||||||
|
mn = Math.min(mn, v);
|
||||||
|
mx = Math.max(mx, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mn === Infinity) return { min: -defaultAbs, max: defaultAbs };
|
||||||
|
|
||||||
|
const min = Math.min(-defaultAbs, Math.floor(mn));
|
||||||
|
const max = Math.max(defaultAbs, Math.ceil(mx));
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- prefs ---------------- */
|
/* ---------------- prefs ---------------- */
|
||||||
|
|
||||||
const LS_GROUP = "stviz:v1:stats:group";
|
const LS_GROUP = "stviz:v1:stats:group";
|
||||||
|
|
@ -495,6 +542,8 @@ export async function renderStats($app) {
|
||||||
<div class="small" style="white-space:nowrap; opacity:.75;">Price</div>
|
<div class="small" style="white-space:nowrap; opacity:.75;">Price</div>
|
||||||
|
|
||||||
<div class="rangeDual" aria-label="Price range">
|
<div class="rangeDual" aria-label="Price range">
|
||||||
|
<div class="rangeTrack"></div>
|
||||||
|
<div class="rangeFill" id="statsRangeFill"></div>
|
||||||
<input id="statsMinPrice" type="range" min="0" max="1000" step="1" value="0" />
|
<input id="statsMinPrice" type="range" min="0" max="1000" step="1" value="0" />
|
||||||
<input id="statsMaxPrice" type="range" min="0" max="1000" step="1" value="1000" />
|
<input id="statsMaxPrice" type="range" min="0" max="1000" step="1" value="1000" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -523,6 +572,7 @@ export async function renderStats($app) {
|
||||||
|
|
||||||
const $minR = document.getElementById("statsMinPrice");
|
const $minR = document.getElementById("statsMinPrice");
|
||||||
const $maxR = document.getElementById("statsMaxPrice");
|
const $maxR = document.getElementById("statsMaxPrice");
|
||||||
|
const $fill = document.getElementById("statsRangeFill");
|
||||||
const $priceLabel = document.getElementById("statsPriceLabel");
|
const $priceLabel = document.getElementById("statsPriceLabel");
|
||||||
const $priceWrap = document.getElementById("statsPriceWrap");
|
const $priceWrap = document.getElementById("statsPriceWrap");
|
||||||
|
|
||||||
|
|
@ -571,6 +621,7 @@ export async function renderStats($app) {
|
||||||
const t = Number.isFinite(v) ? v / 1000 : 1;
|
const t = Number.isFinite(v) ? v / 1000 : 1;
|
||||||
return priceFromT(t);
|
return priceFromT(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRangeZ() {
|
function updateRangeZ() {
|
||||||
// help when thumbs overlap
|
// help when thumbs overlap
|
||||||
const a = Number($minR.value);
|
const a = Number($minR.value);
|
||||||
|
|
@ -584,9 +635,21 @@ export async function renderStats($app) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateRangeFill() {
|
||||||
|
if (!$fill) return;
|
||||||
|
const a = Number($minR.value) || 0; // 0..1000
|
||||||
|
const b = Number($maxR.value) || 1000;
|
||||||
|
const lo = Math.min(a, b) / 1000;
|
||||||
|
const hi = Math.max(a, b) / 1000;
|
||||||
|
$fill.style.left = `${(lo * 100).toFixed(2)}%`;
|
||||||
|
$fill.style.right = `${((1 - hi) * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
function updatePriceLabel() {
|
function updatePriceLabel() {
|
||||||
if (!$priceLabel) return;
|
if (!$priceLabel) return;
|
||||||
$priceLabel.textContent = `${formatDollars(selectedMinPrice)} – ${formatDollars(selectedMaxPrice)}`;
|
$priceLabel.textContent = `${formatDollars(
|
||||||
|
selectedMinPrice
|
||||||
|
)} – ${formatDollars(selectedMaxPrice)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveFilterPrefs(group, size) {
|
function saveFilterPrefs(group, size) {
|
||||||
|
|
@ -615,16 +678,16 @@ export async function renderStats($app) {
|
||||||
return { q, minP, maxP };
|
return { q, minP, maxP };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drawOrUpdateChart({ labels, stores, seriesByStore }) {
|
async function drawOrUpdateChart(series, yBounds) {
|
||||||
|
const { labels, stores, seriesByStore } = series;
|
||||||
|
|
||||||
const Chart = await ensureChartJs();
|
const Chart = await ensureChartJs();
|
||||||
const canvas = document.getElementById("statsChart");
|
const canvas = document.getElementById("statsChart");
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
const datasets = stores.map((s) => ({
|
const datasets = stores.map((s) => ({
|
||||||
label: displayStoreName(s),
|
label: displayStoreName(s),
|
||||||
data: Array.isArray(seriesByStore[s])
|
data: Array.isArray(seriesByStore[s]) ? seriesByStore[s] : labels.map(() => null),
|
||||||
? seriesByStore[s]
|
|
||||||
: labels.map(() => null),
|
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
tension: 0.15,
|
tension: 0.15,
|
||||||
}));
|
}));
|
||||||
|
|
@ -632,6 +695,10 @@ export async function renderStats($app) {
|
||||||
if (_chart) {
|
if (_chart) {
|
||||||
_chart.data.labels = labels;
|
_chart.data.labels = labels;
|
||||||
_chart.data.datasets = datasets;
|
_chart.data.datasets = datasets;
|
||||||
|
if (yBounds) {
|
||||||
|
_chart.options.scales.y.min = yBounds.min;
|
||||||
|
_chart.options.scales.y.max = yBounds.max;
|
||||||
|
}
|
||||||
_chart.update("none");
|
_chart.update("none");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -663,6 +730,8 @@ export async function renderStats($app) {
|
||||||
ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 },
|
ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12 },
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
|
min: yBounds?.min,
|
||||||
|
max: yBounds?.max,
|
||||||
title: { display: true, text: "Avg % vs per-SKU median" },
|
title: { display: true, text: "Avg % vs per-SKU median" },
|
||||||
ticks: { callback: (v) => `${Number(v).toFixed(0)}%`, maxTicksLimit: 12 },
|
ticks: { callback: (v) => `${Number(v).toFixed(0)}%`, maxTicksLimit: 12 },
|
||||||
},
|
},
|
||||||
|
|
@ -674,7 +743,7 @@ export async function renderStats($app) {
|
||||||
let raw = null; // loaded series (reports cached in-memory)
|
let raw = null; // loaded series (reports cached in-memory)
|
||||||
let applyTimer = null;
|
let applyTimer = null;
|
||||||
|
|
||||||
async function rerender(loadOnly = false) {
|
async function rerender() {
|
||||||
destroyStatsChart();
|
destroyStatsChart();
|
||||||
|
|
||||||
const group = String($group?.value || "all");
|
const group = String($group?.value || "all");
|
||||||
|
|
@ -690,9 +759,13 @@ export async function renderStats($app) {
|
||||||
const b = computePriceBoundsFromReport(newestReport, raw.stores);
|
const b = computePriceBoundsFromReport(newestReport, raw.stores);
|
||||||
|
|
||||||
// dynamic floor: if we have a real min, use it (but keep >= 1); else default to 25
|
// dynamic floor: if we have a real min, use it (but keep >= 1); else default to 25
|
||||||
const floor = Number.isFinite(b.min) && b.min > 0 ? Math.max(1, Math.floor(b.min)) : 25;
|
const floor =
|
||||||
|
Number.isFinite(b.min) && b.min > 0 ? Math.max(1, Math.floor(b.min)) : 25;
|
||||||
boundMin = floor;
|
boundMin = floor;
|
||||||
boundMax = Number.isFinite(b.max) && b.max > boundMin ? Math.ceil(b.max) : Math.max(boundMin, 1000);
|
boundMax =
|
||||||
|
Number.isFinite(b.max) && b.max > boundMin
|
||||||
|
? Math.ceil(b.max)
|
||||||
|
: Math.max(boundMin, 1000);
|
||||||
|
|
||||||
// hydrate UI from prefs (and clamp to bounds)
|
// hydrate UI from prefs (and clamp to bounds)
|
||||||
const saved = loadFilterPrefs(group, size);
|
const saved = loadFilterPrefs(group, size);
|
||||||
|
|
@ -707,6 +780,8 @@ export async function renderStats($app) {
|
||||||
selectedMaxPrice = boundMax;
|
selectedMaxPrice = boundMax;
|
||||||
setSliderFromPrice($minR, boundMin);
|
setSliderFromPrice($minR, boundMin);
|
||||||
setSliderFromPrice($maxR, boundMax);
|
setSliderFromPrice($maxR, boundMax);
|
||||||
|
updateRangeZ();
|
||||||
|
updateRangeFill();
|
||||||
updatePriceLabel();
|
updatePriceLabel();
|
||||||
} else {
|
} else {
|
||||||
$minR.disabled = false;
|
$minR.disabled = false;
|
||||||
|
|
@ -724,11 +799,10 @@ export async function renderStats($app) {
|
||||||
setSliderFromPrice($minR, selectedMinPrice);
|
setSliderFromPrice($minR, selectedMinPrice);
|
||||||
setSliderFromPrice($maxR, selectedMaxPrice);
|
setSliderFromPrice($maxR, selectedMaxPrice);
|
||||||
updateRangeZ();
|
updateRangeZ();
|
||||||
|
updateRangeFill();
|
||||||
updatePriceLabel();
|
updatePriceLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadOnly) return;
|
|
||||||
|
|
||||||
// apply filters and draw
|
// apply filters and draw
|
||||||
const tokens = tokenizeQuery($q?.value || "");
|
const tokens = tokenizeQuery($q?.value || "");
|
||||||
const series = computeSeriesFromRaw(raw, {
|
const series = computeSeriesFromRaw(raw, {
|
||||||
|
|
@ -737,14 +811,16 @@ export async function renderStats($app) {
|
||||||
maxPrice: selectedMaxPrice,
|
maxPrice: selectedMaxPrice,
|
||||||
});
|
});
|
||||||
|
|
||||||
await drawOrUpdateChart(series);
|
const abs = group === "all" ? 12 : 8;
|
||||||
|
const yBounds = computeYBounds(series.seriesByStore, abs);
|
||||||
|
|
||||||
const rel = relReportPath(group, size);
|
await drawOrUpdateChart(series, yBounds);
|
||||||
onStatus(
|
|
||||||
`Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}. Source=${esc(
|
const short = `Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}.`;
|
||||||
rel
|
onStatus(short);
|
||||||
)} @ ${esc(raw.latestSha.slice(0, 7))}`
|
if ($status) {
|
||||||
);
|
$status.title = `Source: ${relReportPath(group, size)} @ ${raw.latestSha.slice(0, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
saveFilterPrefs(group, size);
|
saveFilterPrefs(group, size);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -771,13 +847,16 @@ export async function renderStats($app) {
|
||||||
maxPrice: selectedMaxPrice,
|
maxPrice: selectedMaxPrice,
|
||||||
});
|
});
|
||||||
|
|
||||||
await drawOrUpdateChart(series);
|
const abs = group === "all" ? 12 : 8;
|
||||||
|
const yBounds = computeYBounds(series.seriesByStore, abs);
|
||||||
|
|
||||||
onStatus(
|
await drawOrUpdateChart(series, yBounds);
|
||||||
`Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}. Source=${esc(
|
|
||||||
relReportPath(group, size)
|
const short = `Loaded ${series.labels.length} day(s). Filtered SKUs: ${series.newestUsed}/${series.newestTotal}.`;
|
||||||
)} @ ${esc(raw.latestSha.slice(0, 7))}`
|
onStatus(short);
|
||||||
);
|
if ($status) {
|
||||||
|
$status.title = `Source: ${relReportPath(group, size)} @ ${raw.latestSha.slice(0, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
saveFilterPrefs(group, size);
|
saveFilterPrefs(group, size);
|
||||||
}, ms);
|
}, ms);
|
||||||
|
|
@ -805,20 +884,21 @@ export async function renderStats($app) {
|
||||||
setSliderFromPrice($minR, selectedMinPrice);
|
setSliderFromPrice($minR, selectedMinPrice);
|
||||||
setSliderFromPrice($maxR, selectedMaxPrice);
|
setSliderFromPrice($maxR, selectedMaxPrice);
|
||||||
updateRangeZ();
|
updateRangeZ();
|
||||||
|
updateRangeFill();
|
||||||
updatePriceLabel();
|
updatePriceLabel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial load
|
// initial load
|
||||||
await rerender(false);
|
await rerender();
|
||||||
|
|
||||||
// dropdowns: reload raw series + rehydrate filters (clamped)
|
// dropdowns: reload raw series + rehydrate filters (clamped)
|
||||||
$group?.addEventListener("change", async () => {
|
$group?.addEventListener("change", async () => {
|
||||||
onStatus("Loading…");
|
onStatus("Loading…");
|
||||||
await rerender(false);
|
await rerender();
|
||||||
});
|
});
|
||||||
$size?.addEventListener("change", async () => {
|
$size?.addEventListener("change", async () => {
|
||||||
onStatus("Loading…");
|
onStatus("Loading…");
|
||||||
await rerender(false);
|
await rerender();
|
||||||
});
|
});
|
||||||
|
|
||||||
// search: realtime
|
// search: realtime
|
||||||
|
|
@ -861,9 +941,14 @@ export async function renderStats($app) {
|
||||||
setSliderFromPrice($minR, selectedMinPrice);
|
setSliderFromPrice($minR, selectedMinPrice);
|
||||||
setSliderFromPrice($maxR, selectedMaxPrice);
|
setSliderFromPrice($maxR, selectedMaxPrice);
|
||||||
updateRangeZ();
|
updateRangeZ();
|
||||||
|
updateRangeFill();
|
||||||
updatePriceLabel();
|
updatePriceLabel();
|
||||||
|
|
||||||
applyFiltersDebounced(0);
|
applyFiltersDebounced(0);
|
||||||
$q?.focus();
|
$q?.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ensure fill is correct on first paint
|
||||||
|
updateRangeZ();
|
||||||
|
updateRangeFill();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -488,7 +488,19 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; }
|
||||||
border-color: rgba(200, 120, 20, 0.28);
|
border-color: rgba(200, 120, 20, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Stats page: dual range slider --- */
|
/* Prevent layout width shift when scrollbar appears/disappears */
|
||||||
|
html { overflow-y: scroll; }
|
||||||
|
|
||||||
|
/* Prevent long status text from forcing header wrap */
|
||||||
|
#statsStatus{
|
||||||
|
max-width: 52ch;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Stats dual range slider: custom track + fill (no native progress) --- */
|
||||||
.rangeDual {
|
.rangeDual {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
@ -496,6 +508,28 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; }
|
||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rangeDual .rangeTrack,
|
||||||
|
.rangeDual .rangeFill {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeDual .rangeTrack {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeDual .rangeFill {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #37566b; /* matches your focus outline color */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide native track/progress; keep thumbs clickable */
|
||||||
.rangeDual input[type="range"] {
|
.rangeDual input[type="range"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -504,10 +538,14 @@ a.skuLink:hover { text-decoration: underline; cursor: pointer; }
|
||||||
height: 18px;
|
height: 18px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
accent-color: #9aa3b2;
|
pointer-events: none;
|
||||||
pointer-events: none; /* allow both sliders to be interactable via thumbs */
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* thumbs must still receive pointer events */
|
.rangeDual input[type="range"]::-webkit-slider-runnable-track { background: transparent; }
|
||||||
.rangeDual input[type="range"]::-webkit-slider-thumb { pointer-events: all; }
|
.rangeDual input[type="range"]::-webkit-slider-thumb { pointer-events: all; }
|
||||||
|
|
||||||
|
.rangeDual input[type="range"]::-moz-range-track { background: transparent; }
|
||||||
|
.rangeDual input[type="range"]::-moz-range-progress { background: transparent; }
|
||||||
.rangeDual input[type="range"]::-moz-range-thumb { pointer-events: all; }
|
.rangeDual input[type="range"]::-moz-range-thumb { pointer-events: all; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue