diff --git a/public/style.css b/public/style.css index f34004ab..2ea4a62f 100644 --- a/public/style.css +++ b/public/style.css @@ -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 */ diff --git a/test-analytics-fluid-charts.js b/test-analytics-fluid-charts.js new file mode 100644 index 00000000..b160700b --- /dev/null +++ b/test-analytics-fluid-charts.js @@ -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) => + `
` + + `

Card

` + + `
Desc
` + + `` + + `
`; + return ` + + + + +
+
+ ${card(false)}${card(false)}${card(false)}${card(false)} +
+
+ `; +} + +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); +})();