fix(#1058): fluid + container-queried analytics chart grid (#1098)

## Summary
Makes the analytics chart grid fluid and auto-stacking based on its
**own** width rather than the viewport's. Implements task 5 of #1050.

## What changed
- `public/style.css` — `.analytics-charts` section only:
- Replaced `grid-template-columns: 1fr 1fr` with `repeat(auto-fit,
minmax(min(100%, 380px), 1fr))` so columns wrap when intrinsic space is
too narrow.
- Added `container-type: inline-size` so the grid is a query container
and descendants/future tweaks can size against its own width rather than
the viewport. The `auto-fit minmax` already handles the stack-on-narrow
case, so the previously-included `@container (max-width: 800px)` rule
was redundant and has been dropped to keep one source of truth.
- `min-width: 0` on cards and `max-width: 100%; height: auto` on
`<svg>`/`<canvas>` (descendant selector, robust to wrapper elements
between the card and the chart media) to prevent intrinsic-content
overflow.
- Switched hardcoded `12px` / `16px` spacing to the #1054 tokens
`--space-sm` / `--space-md`.
- Removed the redundant `@media (max-width: 768px) { .analytics-charts {
grid-template-columns: 1fr; } }` rule (the fluid grid supersedes it).

No `analytics.js` / `node-analytics.js` markup changes were required —
the existing classes are reused.

## TDD
- **Red commit (47f56e9)** — `test-analytics-fluid-charts.js`: failing
E2E that loads `public/style.css` against a sized harness and asserts no
overflow + correct stacking. On master: assertion failures on
container-type opt-in + wide-viewport / narrow-container stacking.
- **Green commit (d300dfa)** — CSS fix; all assertions pass.

## E2E (mandatory frontend coverage)
`node test-analytics-fluid-charts.js` — Playwright + Chromium against a
`file://` harness, 8/8 assertions:
- `.analytics-charts` opts in to container queries (`container-type:
inline-size`)
- viewport 1440 / wrapper 1300px → side-by-side (≥2 cols), no overflow
- viewport 1080 / wrapper 1040px → no horizontal overflow
- viewport 768 / wrapper 760px → cards stack to 1 column, no overflow
- viewport 1440 / wrapper 600px → cards stack via fluid grid (the
original bug)
- viewport 1920 / wrapper 1880px → side-by-side (≥2 cols), no overflow
(AC4)
- viewport 2560 / wrapper 2520px → side-by-side (≥2 cols), no overflow
(AC4)
- AC3: open at 1440px wide (side-by-side), shrink wrapper to 760px /
viewport to 768px, assert layout reflows to 1 column (charts redraw on
resize, not stuck at initial value)

`node test-fluid-scaffolding.js` — still green (15/15), confirms #1054
tokens are unaffected.

Partial fix for #1058

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
This commit is contained in:
Kpa-clawbot
2026-05-05 08:04:25 -07:00
committed by GitHub
parent ade7513693
commit 6d17cac40e
2 changed files with 227 additions and 3 deletions
+22 -3
View File
@@ -1959,9 +1959,28 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.analytics-stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 2px; }
.analytics-stat-value { font-size: 20px; font-weight: 700; }
.analytics-stat-desc { font-size: 10px; color: var(--text-muted); margin-top: 2px; font-style: italic; }
.analytics-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
.analytics-chart-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px; }
.analytics-charts {
/* #1058 — fluid + auto-stacking layout. The grid sizes from its own
available width (NOT the viewport), so a narrow side-pane on a wide
screen still stacks. `auto-fit` collapses empty tracks; `minmax()`
guarantees a minimum readable column width before wrapping. The
`container-type: inline-size` opts in to container queries so
descendants (or future tweaks) can size against this element's
own width rather than the viewport. Uses #1054 spacing tokens. */
container-type: inline-size;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.analytics-chart-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: var(--space-sm); min-width: 0; }
.analytics-chart-card.full { grid-column: 1 / -1; }
/* Constrain chart media inside the card (svg/canvas at any depth). The
`.analytics-chart-card svg, .analytics-chart-card canvas` descendant
selector is robust to wrapper elements (legends, tooltips, axis
groups) being added between the card and the chart media. */
.analytics-chart-card svg,
.analytics-chart-card canvas { max-width: 100%; height: auto; display: block; }
.analytics-chart-card h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 4px; }
.analytics-chart-desc { font-size: 10px; color: var(--text-muted); margin-bottom: 8px; font-style: italic; }
.analytics-heatmap { display: grid; grid-template-columns: 40px repeat(24, 1fr); gap: 2px; }
@@ -1974,7 +1993,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.analytics-peer-table th { text-align: left; padding: 6px 8px; border-bottom: 2px solid var(--border); color: var(--text-muted); font-size: 11px; text-transform: uppercase; }
.analytics-peer-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
.analytics-peer-table tr:hover td { background: var(--card-bg); }
@media (max-width: 768px) { .analytics-stats { grid-template-columns: repeat(2, 1fr); } .analytics-charts { grid-template-columns: 1fr; } }
@media (max-width: 768px) { .analytics-stats { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 480px) { .analytics-stats { grid-template-columns: 1fr; } }
/* Claimed (My Mesh) node rows */
+205
View File
@@ -0,0 +1,205 @@
/**
* E2E (#1058): Analytics chart containers — fluid + auto-stacking via
* container queries.
*
* Boots Chromium with a minimal HTML harness that links public/style.css
* and renders the .analytics-charts grid at 768/1080/1440 viewports.
*
* Asserts:
* - No horizontal overflow of the chart grid (scrollWidth <= clientWidth).
* - Cards STACK (single column) when the .analytics-charts container is
* narrower than 800px.
* - Cards are SIDE-BY-SIDE (≥2 columns) when the container is at least
* 1200px wide.
* - The .analytics-charts element opts in to container queries via
* `container-type: inline-size`.
*
* Pure file:// harness — does not require the Go server.
*
* Usage: node test-analytics-fluid-charts.js
*/
'use strict';
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const os = require('os');
const CSS_PATH = path.join(__dirname, 'public', 'style.css');
const cssHref = 'file://' + CSS_PATH;
// Minimal harness: a sized wrapper that defines the available width
// for the .analytics-charts container, plus a handful of chart cards
// matching the production markup.
function harnessHTML(wrapperWidth) {
const card = (full) =>
`<div class="analytics-chart-card${full ? ' full' : ''}">` +
`<h4>Card</h4>` +
`<div class="analytics-chart-desc">Desc</div>` +
`<svg viewBox="0 0 800 200" style="width:100%;max-height:160px"><rect width="800" height="200" fill="#888"/></svg>` +
`</div>`;
return `<!doctype html><html><head>
<meta charset="utf-8">
<link rel="stylesheet" href="${cssHref}">
<style>
/* Sized wrapper simulates the page's content column width — the
.analytics-charts inside MUST stay fluid relative to this. */
#wrap { width: ${wrapperWidth}px; box-sizing: border-box; padding: 0; margin: 0; }
body { margin: 0; }
</style>
</head><body>
<div id="wrap">
<div class="analytics-charts" id="grid">
${card(false)}${card(false)}${card(false)}${card(false)}
</div>
</div>
</body></html>`;
}
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(async () => {
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium',
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== #1058 Analytics fluid charts E2E ===');
async function load(wrapperWidth, viewportWidth) {
await page.setViewportSize({ width: viewportWidth, height: 900 });
const tmp = path.join(os.tmpdir(),
`1058-harness-${wrapperWidth}-${viewportWidth}.html`);
fs.writeFileSync(tmp, harnessHTML(wrapperWidth));
await page.goto('file://' + tmp, { waitUntil: 'domcontentloaded' });
}
// Helper: count distinct column-x-positions of chart cards.
async function colCount() {
return page.evaluate(() => {
const cards = Array.from(document.querySelectorAll(
'.analytics-charts > .analytics-chart-card'));
const xs = new Set(cards.map(c =>
Math.round(c.getBoundingClientRect().left)));
return xs.size;
});
}
async function overflow() {
return page.evaluate(() => {
const g = document.getElementById('grid');
return { scrollW: g.scrollWidth, clientW: g.clientWidth };
});
}
// --- Container-query opt-in -------------------------------------------
await step('analytics-charts opts in to container queries', async () => {
await load(1200, 1440);
const ct = await page.evaluate(() => {
const g = document.getElementById('grid');
return getComputedStyle(g).containerType;
});
assert(/inline-size|size/.test(ct),
`expected container-type to be inline-size; got "${ct}"`);
});
// --- Viewport 1440: container ≥1200 → side-by-side --------------------
await step('viewport 1440 / wrapper 1300px → side-by-side (≥2 cols)', async () => {
await load(1300, 1440);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 1300px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 1080: medium width — must not overflow ------------------
await step('viewport 1080 / wrapper 1040px → no horizontal overflow', async () => {
await load(1040, 1080);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 768: container <800 → must stack vertically -------------
await step('viewport 768 / wrapper 760px → cards stack (1 col)', async () => {
await load(760, 768);
const cols = await colCount();
assert(cols === 1, `expected 1 column at wrapper 760px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- THE bug: wide viewport + narrow container — must stack ----------
// Today's @media (max-width:768px) is keyed off viewport, not container.
// A narrow wrapper inside a wide viewport (e.g., side pane on a 1440
// screen) should still stack the charts via container queries.
await step('viewport 1440 / wrapper 600px → cards stack via container query', async () => {
await load(600, 1440);
const cols = await colCount();
assert(cols === 1,
`expected 1 column when container <800px regardless of viewport; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at wide-viewport/narrow-container: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 1920: large desktop → side-by-side, no overflow --------
await step('viewport 1920 / wrapper 1880px → side-by-side (≥2 cols), no overflow', async () => {
await load(1880, 1920);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 1880px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at 1920: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- Viewport 2560: ultra-wide → side-by-side, no overflow -----------
await step('viewport 2560 / wrapper 2520px → side-by-side (≥2 cols), no overflow', async () => {
await load(2520, 2560);
const cols = await colCount();
assert(cols >= 2, `expected ≥2 columns at wrapper 2520px; got ${cols}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow at 2560: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
// --- AC3: charts must redraw/relayout on viewport resize -------------
// Open at 1440 wide (side-by-side), then shrink the wrapper to 760
// (sub-800 container) and assert the layout actually flips to a
// single column. This guards against any future regression where
// the grid is computed once and stuck.
await step('AC3: layout reflows on resize (1440 side-by-side → 768 stacked)', async () => {
await load(1300, 1440);
const colsWide = await colCount();
assert(colsWide >= 2,
`precondition failed: expected ≥2 cols at 1300px; got ${colsWide}`);
// Shrink only the wrapper (no full reload) — proves the layout
// recomputes from the current container width, not a one-shot value.
await page.evaluate(() => {
document.getElementById('wrap').style.width = '760px';
});
await page.setViewportSize({ width: 768, height: 900 });
// Give the browser a frame to recompute layout.
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
const colsNarrow = await colCount();
assert(colsNarrow === 1,
`expected layout to reflow to 1 column after shrink; got ${colsNarrow}`);
const o = await overflow();
assert(o.scrollW <= o.clientW + 1,
`horizontal overflow after resize: scrollW=${o.scrollW} clientW=${o.clientW}`);
});
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
})();