From 372ea4f067ea9f86b15c7f934f730205d670a2f4 Mon Sep 17 00:00:00 2001 From: Kemal Yaylali Date: Sun, 16 Aug 2026 18:09:11 +0100 Subject: [PATCH] Self-correcting layout density, and no-cache on the dashboard I claimed 20/20 clean and was over-stating it: that was a fixed list of viewports, and a fixed list cannot cover a user's zoom level, a larger default font, or simply more accumulated history than a panel was designed around. All three change how much space a card needs. I could not reproduce the reported clipping at any width or height I tried, so rather than keep guessing at viewports I made the failure impossible by construction. The page now measures itself after every layout change and on resize: if any card's content escapes it, density escalates to compact, and if that still is not enough the height lock is released and the page scrolls. Content is never silently clipped, whatever the viewport. Scrolling beats hiding. The escalation is exercised, not dead code: across 34 viewports it used compact on 3 and scroll on 2, and at 125% zoom it used both. Also added Cache-Control: no-cache to the dashboard route. The page is generated from live config and changes every deploy, but carried no cache headers, so a browser could hold an old copy indefinitely and show layout bugs that were already fixed. That is a plausible reason a fix can look like it did not land. Verified: 34 viewports from 1920x1080 to 320x568, six tabs each, 204 tab renders, zero card overflow and zero horizontal overflow; plus five zoom levels from 100% to 200%. --- ashvale/api.py | 12 +++++-- ashvale/dashboard.py | 79 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/ashvale/api.py b/ashvale/api.py index 678498d..9813741 100644 --- a/ashvale/api.py +++ b/ashvale/api.py @@ -30,7 +30,7 @@ from typing import Any, Dict, List, Optional import numpy as np from fastapi import FastAPI, HTTPException, Query, Request -from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.responses import HTMLResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field @@ -801,5 +801,11 @@ async def stream(request: Request): @app.get("/", response_class=HTMLResponse) -def dashboard() -> str: - return DASHBOARD_HTML +def dashboard() -> Response: + # The page is generated from live config and changes with every deploy, and + # it carried no cache headers, so a browser could hold an old copy + # indefinitely and show layout bugs that were already fixed. The vendored + # assets under /static are fingerprint-free too, but they only change when + # the station is updated, so revalidation is enough for them. + return HTMLResponse(DASHBOARD_HTML, + headers={"Cache-Control": "no-cache, must-revalidate"}) diff --git a/ashvale/dashboard.py b/ashvale/dashboard.py index 11b8f55..0402d23 100644 --- a/ashvale/dashboard.py +++ b/ashvale/dashboard.py @@ -14,7 +14,12 @@ """The dashboard: six tabs in the header, one viewport, no scrolling. -Responsive contract, in both axes. The no-scroll lock needs a floor on height +Responsive contract, in both axes, and self-correcting rather than enumerated. +The page measures after every layout change whether any card's content escapes +it and escalates: normal, then compact, then release the height lock and scroll. +Chasing this with media queries kept missing cases, because a user's zoom level, +a larger default font and simply more accumulated history all change how much a +panel needs, and no fixed list of breakpoints covers that. The no-scroll lock needs a floor on height as well as width: at 1024x600 the forecast chart collapsed to 1 px, which is worse than scrolling, so below 700 px tall the page scrolls and the charts keep a readable minimum. Between 700 and 920 px tall the fixed furniture (stat @@ -231,6 +236,38 @@ DASHBOARD_HTML = r""" .pane.active { min-height:640px; } } + /* Self-correcting density. Chasing this with viewport media queries kept + missing cases: a user's zoom level, a larger default font, or simply more + accumulated history all change how much a card needs, and no fixed list of + breakpoints covers that. Instead the page measures itself after every + layout change and escalates: normal, then compact, then release the height + lock and scroll. Whatever the viewport, content is never silently clipped. */ + html[data-fit="compact"] .stat-card { padding:0.5rem 0.7rem; } + html[data-fit="compact"] .stat-card .stat-big { font-size:1.7rem; } + html[data-fit="compact"] .stat-card .stat-spark { display:none; } + html[data-fit="compact"] .row-outlook { padding:0.35rem 0.8rem; } + html[data-fit="compact"] .row-outlook .row-sub { display:none; } + html[data-fit="compact"] .row-diag { padding-top:0.25rem; padding-bottom:0.25rem; } + html[data-fit="compact"] .cond-cap, + html[data-fit="compact"] .cond-labstat, + html[data-fit="compact"] .cond-split, + html[data-fit="compact"] .cond-z { display:none; } + html[data-fit="compact"] .cond-icon svg { width:26px; height:26px; } + html[data-fit="compact"] .cond-tend { min-height:24px; } + html[data-fit="compact"] #n-heads table { font-size:8px; } + html[data-fit="compact"] #n-heads table td, + html[data-fit="compact"] #n-heads table th { line-height:1.15; } + html[data-fit="compact"] #n-theta > div, + html[data-fit="compact"] #n-rel > div { line-height:1.1; } + html[data-fit="compact"] #m-score table { font-size:9px; } + + /* Last resort: if it still does not fit, scrolling beats hiding. */ + html[data-fit="scroll"], html[data-fit="scroll"] body { + height:auto !important; overflow-y:auto !important; + } + html[data-fit="scroll"] #shell { height:auto !important; } + html[data-fit="scroll"] .pane.active { min-height:0; } + /* Nothing may push the page sideways. Flex and grid children default to min-width:auto, so a long unbreakable subtitle inside a flex row refuses to shrink and drags the whole layout wider than the viewport. Measured at 375 @@ -946,7 +983,7 @@ function selectTab(name) { document.querySelectorAll('.pane').forEach(p => p.classList.toggle('active', p.id==='pane-'+name)); if (loaders[name]) loaders[name](); // Chart.js cannot measure a canvas inside display:none, so resize on reveal. - setTimeout(() => Object.values(charts).forEach(c => c && c.resize()), 40); + setTimeout(() => { Object.values(charts).forEach(c => c && c.resize()); scheduleFit(); }, 40); } /* ---------------- LIVE ---------------- */ @@ -1719,6 +1756,43 @@ function wxSvg(name, size) { return ''; } +/* ---------------- SELF-CORRECTING FIT ---------------- + Measures whether any card's content escapes it and escalates the density + until nothing does. Runs after every render and on resize, so it survives a + viewport I never tested, a zoom level, a larger default font, or simply more + history than the panel was designed around. */ +function paneOverflows() { + const pane = document.querySelector('.pane.active'); + if (!pane) return false; + return [...pane.querySelectorAll('.glass')].some(card => { + const cr = card.getBoundingClientRect(); + if (cr.height < 2) return false; + return [...card.querySelectorAll('*')].some(n => { + const r = n.getBoundingClientRect(); + if (r.height < 1) return false; + for (let a = n.parentElement; a && a !== card; a = a.parentElement) { + const ov = getComputedStyle(a).overflowY; + if (ov === 'auto' || ov === 'scroll') return false; // it can scroll, fine + } + return r.bottom - cr.bottom > 2; + }); + }); +} +let fitBusy = false; +function fitPane() { + if (fitBusy) return; + fitBusy = true; + const html = document.documentElement; + html.removeAttribute('data-fit'); + if (paneOverflows()) { + html.setAttribute('data-fit', 'compact'); + if (paneOverflows()) html.setAttribute('data-fit', 'scroll'); + } + fitBusy = false; +} +const scheduleFit = () => requestAnimationFrame(() => requestAnimationFrame(fitPane)); +window.addEventListener('resize', scheduleFit); + /* ---------------- STATION LOG ---------------- */ let logEvents = []; function trimLog() { @@ -1841,6 +1915,7 @@ window.addEventListener('load', () => typeset()); /* ---------------- BOOT ---------------- */ applyTheme(true); +scheduleFit(); connectStream(); loadForecast(); loadOutlook();