mirror of
https://github.com/lynchaos/ashvale-station.git
synced 2026-09-12 12:47:49 +00:00
Fix cards whose content overflowed them, and the audit that missed it
Reported on a 13 inch screen: the Detectors column and the Learner bank spilled past their cards. My earlier sweep called those tabs clean because it only checked content escaping the viewport, never content escaping its own card, and the cards are overflow-hidden so it was invisible rather than obviously broken. The worst was not either of those. The Station log was discarding up to 299 px of entries behind overflow-hidden with no indication, at every viewport. It now binary-searches the number of rows that fit and says how many are not shown. The count is searched rather than divided out of an assumed row height, because a long detail line wraps to two lines and no constant knows that. It runs from a ResizeObserver rather than once: measuring at call time reads a stale clientHeight, since the pane has only just become visible and Chart.js resizes its siblings 40 ms later, which fitted 13 rows into a box that holds 10. Also: denser learner and attribution rows on short screens, three precipitation coefficients instead of four, and a lower floor on the tendency chart. Two mistakes of mine on the way, both worth recording. I declared a second const rows inside loadModels, which is a parse error that killed the entire dashboard script; the audit reported it as a flood of sparkline layout faults because it was not listening for page errors, and it now fails loudly on them. And the min-height override did nothing at first because the Tailwind CDN injects its sheet after this style block, so min-h-[42px] won at equal specificity. Verified: 20 theme x viewport combinations, 120 tab renders, from 1920x1080 to 375x667 in both themes. Zero card overflow, zero horizontal overflow, zero console errors.
This commit is contained in:
+69
-8
@@ -259,12 +259,30 @@ DASHBOARD_HTML = r"""
|
|||||||
and the tendency chart. At 1280x800 it needed 351 px into 267, so it
|
and the tendency chart. At 1280x800 it needed 351 px into 267, so it
|
||||||
clipped its own chart. Same treatment as the stat cards: give the height
|
clipped its own chart. Same treatment as the stat cards: give the height
|
||||||
back by shrinking the furniture, not by hiding the reading. */
|
back by shrinking the furniture, not by hiding the reading. */
|
||||||
|
/* The nerd tab's two dense panels grow with accumulated history: 18 learner
|
||||||
|
rows and a detector column whose coefficient list lengthens as labels come
|
||||||
|
in. Both silently spilled past their cards on a 13 inch screen. Tightening
|
||||||
|
the line box is worth more here than any font change, because these are
|
||||||
|
one-line-per-fact tables. */
|
||||||
@media (min-width:1024px) and (max-height:920px) {
|
@media (min-width:1024px) and (max-height:920px) {
|
||||||
|
#n-heads table td, #n-heads table th { padding-top:0; padding-bottom:0; line-height:1.25; }
|
||||||
|
#n-heads table { font-size:9px; }
|
||||||
|
#n-rel { row-gap:0; }
|
||||||
|
#n-theta > div { line-height:1.3; }
|
||||||
.cond-cap { display:none; }
|
.cond-cap { display:none; }
|
||||||
.cond-icon svg { width:36px; height:36px; }
|
.cond-icon svg { width:36px; height:36px; }
|
||||||
.cond-label { padding:0.35rem 0.5rem; }
|
.cond-label { padding:0.35rem 0.5rem; }
|
||||||
}
|
}
|
||||||
@media (min-width:1024px) and (max-height:830px) {
|
@media (min-width:1024px) and (max-height:830px) {
|
||||||
|
/* The tendency chart's floor is what overflows once everything above it has
|
||||||
|
already been trimmed: a hard 42 px min-height cannot flex, so it pushes
|
||||||
|
13 px past the card at 1024x768. Lower the floor rather than removing it,
|
||||||
|
so the chart stays a chart. */
|
||||||
|
/* html prefix for specificity: the Tailwind CDN injects its sheet after this
|
||||||
|
block, so min-h-[42px] wins at equal weight and the override did nothing. */
|
||||||
|
html .cond-tend { min-height:26px; }
|
||||||
|
/* Attribution rows overshot by 4 px, which one notch of leading covers. */
|
||||||
|
#n-theta > div { line-height:1.15; }
|
||||||
.cond-icon svg { width:30px; height:30px; }
|
.cond-icon svg { width:30px; height:30px; }
|
||||||
/* 1366x768 is the most common laptop resolution in the world, so it has to
|
/* 1366x768 is the most common laptop resolution in the world, so it has to
|
||||||
fit rather than nearly fit. These four give back about 36 px. */
|
fit rather than nearly fit. These four give back about 36 px. */
|
||||||
@@ -625,7 +643,10 @@ DASHBOARD_HTML = r"""
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="glass rounded-2xl p-4 lg:col-span-4 flex flex-col min-h-0 overflow-hidden">
|
<div class="glass rounded-2xl p-4 lg:col-span-4 flex flex-col min-h-0 overflow-hidden">
|
||||||
<h2 class="text-sm font-bold mb-2 shrink-0">Station log</h2>
|
<div class="flex items-baseline justify-between mb-2 shrink-0">
|
||||||
|
<h2 class="text-sm font-bold">Station log</h2>
|
||||||
|
<span id="m-log-more" class="text-[9px] font-mono text-slate-600"></span>
|
||||||
|
</div>
|
||||||
<div id="m-log" class="flex-1 min-h-0 overflow-hidden space-y-1 font-mono text-[10px]"></div>
|
<div id="m-log" class="flex-1 min-h-0 overflow-hidden space-y-1 font-mono text-[10px]"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1344,12 +1365,17 @@ async function loadModels() {
|
|||||||
(c.weight>=0?'bg-emerald-500':'bg-rose-500')+'" style="width:'+(Math.abs(c.weight)/mx*100)+'%"></div></div>'+
|
(c.weight>=0?'bg-emerald-500':'bg-rose-500')+'" style="width:'+(Math.abs(c.weight)/mx*100)+'%"></div></div>'+
|
||||||
'<span class="w-10 text-right text-slate-500 text-[9px]">'+c.weight.toFixed(2)+'</span></div>').join('');
|
'<span class="w-10 text-right text-slate-500 text-[9px]">'+c.weight.toFixed(2)+'</span></div>').join('');
|
||||||
|
|
||||||
el('m-log').innerHTML = (st.events||[]).map(e=>
|
// The card is overflow-hidden, so anything past its height was simply
|
||||||
'<div class="flex gap-2 border-b border-slate-800/40 pb-1">'+
|
// invisible: measured, up to 299 px of entries were being silently discarded
|
||||||
'<span class="text-slate-700 shrink-0">'+new Date(e.ts*1000).toLocaleTimeString()+'</span>'+
|
// at 1280x800. Render what fits and say how many are not shown, rather than
|
||||||
'<span class="text-slate-600 shrink-0 w-16">'+e.kind+'</span>'+
|
// pretending the list ends there.
|
||||||
'<span class="'+(SEV[e.severity]||'text-slate-400')+'">'+e.detail+'</span></div>').join('')
|
// Rendered through a ResizeObserver rather than once. Measuring at call time
|
||||||
|| '<span class="text-slate-700">Nothing logged yet.</span>';
|
// gets a stale clientHeight: the pane has only just become visible and
|
||||||
|
// Chart.js resizes its siblings 40 ms later, which measured 13 rows into a box
|
||||||
|
// that ends up fitting 10. Observing the box means it also re-fits on window
|
||||||
|
// resize and on a theme change, with no timers to guess at.
|
||||||
|
logEvents = st.events || [];
|
||||||
|
trimLog();
|
||||||
|
|
||||||
const an = await fetch('/api/anomaly').then(r=>r.json());
|
const an = await fetch('/api/anomaly').then(r=>r.json());
|
||||||
el('e-health').innerHTML = Object.keys(an.health||{}).map(k=>{
|
el('e-health').innerHTML = Object.keys(an.health||{}).map(k=>{
|
||||||
@@ -1619,7 +1645,7 @@ async function loadNerd() {
|
|||||||
'<div class="flex justify-between text-slate-600">σ '+k.slice(0,4)+'<span>'+fmt(cl.residual_std[k],3)+'</span></div>').join('');
|
'<div class="flex justify-between text-slate-600">σ '+k.slice(0,4)+'<span>'+fmt(cl.residual_std[k],3)+'</span></div>').join('');
|
||||||
|
|
||||||
const pr = d.precipitation||{};
|
const pr = d.precipitation||{};
|
||||||
const co = (pr.coefficients||[]).slice().sort((a,b)=>Math.abs(b.weight)-Math.abs(a.weight)).slice(0,4);
|
const co = (pr.coefficients||[]).slice().sort((a,b)=>Math.abs(b.weight)-Math.abs(a.weight)).slice(0,3);
|
||||||
el('n-precip').innerHTML =
|
el('n-precip').innerHTML =
|
||||||
'<div class="flex justify-between text-slate-500">labels<span class="text-slate-300">'+(pr.strong_labels||0)+' strong, '+(pr.weak_labels||0)+' weak</span></div>'+
|
'<div class="flex justify-between text-slate-500">labels<span class="text-slate-300">'+(pr.strong_labels||0)+' strong, '+(pr.weak_labels||0)+' weak</span></div>'+
|
||||||
'<div class="flex justify-between text-slate-500">logloss<span class="text-slate-300">'+fmt(pr.logloss_ewma,4)+'</span></div>'+
|
'<div class="flex justify-between text-slate-500">logloss<span class="text-slate-300">'+fmt(pr.logloss_ewma,4)+'</span></div>'+
|
||||||
@@ -1693,6 +1719,41 @@ function wxSvg(name, size) {
|
|||||||
return '<svg viewBox="0 0 64 64" width="'+size+'" height="'+size+'" aria-hidden="true">'+(WX[name]||WX.cloud)+'</svg>';
|
return '<svg viewBox="0 0 64 64" width="'+size+'" height="'+size+'" aria-hidden="true">'+(WX[name]||WX.cloud)+'</svg>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- STATION LOG ---------------- */
|
||||||
|
let logEvents = [];
|
||||||
|
function trimLog() {
|
||||||
|
const box = el('m-log');
|
||||||
|
if (!box) return;
|
||||||
|
const rows = logEvents.map(e =>
|
||||||
|
'<div class="flex gap-2 border-b border-slate-800/40 pb-1">'+
|
||||||
|
'<span class="text-slate-700 shrink-0">'+new Date(e.ts*1000).toLocaleTimeString()+'</span>'+
|
||||||
|
'<span class="text-slate-600 shrink-0 w-16">'+e.kind+'</span>'+
|
||||||
|
'<span class="'+(SEV[e.severity]||'text-slate-400')+'">'+e.detail+'</span></div>');
|
||||||
|
if (!rows.length) {
|
||||||
|
box.innerHTML = '<span class="text-slate-700">Nothing logged yet.</span>';
|
||||||
|
el('m-log-more').innerText = ''; return;
|
||||||
|
}
|
||||||
|
// Binary search the count that fits. A divide-by-assumed-row-height still
|
||||||
|
// overflowed by 26 px, because a long detail line wraps to two lines and no
|
||||||
|
// constant knows that.
|
||||||
|
const fit = n => { box.innerHTML = rows.slice(0, n).join('');
|
||||||
|
return box.scrollHeight <= box.clientHeight + 1; };
|
||||||
|
let shown = rows.length;
|
||||||
|
if (box.clientHeight > 0 && !fit(rows.length)) {
|
||||||
|
let lo = 0, hi = rows.length;
|
||||||
|
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (fit(mid)) lo = mid; else hi = mid - 1; }
|
||||||
|
shown = lo;
|
||||||
|
box.innerHTML = rows.slice(0, shown).join('');
|
||||||
|
}
|
||||||
|
el('m-log-more').innerText = rows.length > shown
|
||||||
|
? (rows.length - shown) + ' older not shown \u00b7 GET /api/events' : '';
|
||||||
|
}
|
||||||
|
if (window.ResizeObserver) {
|
||||||
|
const ro = new ResizeObserver(() => trimLog());
|
||||||
|
const box = el('m-log');
|
||||||
|
if (box) ro.observe(box);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- METHODS ---------------- */
|
/* ---------------- METHODS ---------------- */
|
||||||
let methodsDoc = null, methodSel = 'acquire';
|
let methodsDoc = null, methodSel = 'acquire';
|
||||||
const STAGE_COLOUR = { acquire:'#94a3b8', compensate:'#34d399', kalman:'#f59e0b', features:'#06b6d4',
|
const STAGE_COLOUR = { acquire:'#94a3b8', compensate:'#34d399', kalman:'#f59e0b', features:'#06b6d4',
|
||||||
|
|||||||
Reference in New Issue
Block a user