From ca1d6ba528c57b894b2f0255002c27261b5bc21b Mon Sep 17 00:00:00 2001 From: Kemal Yaylali Date: Sun, 16 Aug 2026 17:53:59 +0100 Subject: [PATCH] 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. --- ashvale/dashboard.py | 77 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/ashvale/dashboard.py b/ashvale/dashboard.py index 7f08852..11b8f55 100644 --- a/ashvale/dashboard.py +++ b/ashvale/dashboard.py @@ -259,12 +259,30 @@ DASHBOARD_HTML = r""" 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 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) { + #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-icon svg { width:36px; height:36px; } .cond-label { padding:0.35rem 0.5rem; } } @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; } /* 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. */ @@ -625,7 +643,10 @@ DASHBOARD_HTML = r"""
-

Station log

+
+

Station log

+ +
@@ -1344,12 +1365,17 @@ async function loadModels() { (c.weight>=0?'bg-emerald-500':'bg-rose-500')+'" style="width:'+(Math.abs(c.weight)/mx*100)+'%">'+ ''+c.weight.toFixed(2)+'').join(''); - el('m-log').innerHTML = (st.events||[]).map(e=> - '
'+ - ''+new Date(e.ts*1000).toLocaleTimeString()+''+ - ''+e.kind+''+ - ''+e.detail+'
').join('') - || 'Nothing logged yet.'; + // The card is overflow-hidden, so anything past its height was simply + // invisible: measured, up to 299 px of entries were being silently discarded + // at 1280x800. Render what fits and say how many are not shown, rather than + // pretending the list ends there. + // Rendered through a ResizeObserver rather than once. Measuring at call time + // 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()); el('e-health').innerHTML = Object.keys(an.health||{}).map(k=>{ @@ -1619,7 +1645,7 @@ async function loadNerd() { '
σ '+k.slice(0,4)+''+fmt(cl.residual_std[k],3)+'
').join(''); 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 = '
labels'+(pr.strong_labels||0)+' strong, '+(pr.weak_labels||0)+' weak
'+ '
logloss'+fmt(pr.logloss_ewma,4)+'
'+ @@ -1693,6 +1719,41 @@ function wxSvg(name, size) { return ''; } +/* ---------------- STATION LOG ---------------- */ +let logEvents = []; +function trimLog() { + const box = el('m-log'); + if (!box) return; + const rows = logEvents.map(e => + '
'+ + ''+new Date(e.ts*1000).toLocaleTimeString()+''+ + ''+e.kind+''+ + ''+e.detail+'
'); + if (!rows.length) { + box.innerHTML = 'Nothing logged yet.'; + 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 ---------------- */ let methodsDoc = null, methodSel = 'acquire'; const STAGE_COLOUR = { acquire:'#94a3b8', compensate:'#34d399', kalman:'#f59e0b', features:'#06b6d4',