mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 11:54:43 +00:00
b52a938b27
## Summary Fixes #1059 — Task 6 of #1050. Makes map controls + modals fluid and safely capped so they work across 768px–2560px viewports. ## Changes `public/style.css` only — modal section + map-controls section (per task scope). ### Map controls (`.map-controls`) - `width: clamp(160px, 18vw, 240px)` — fluid, scales with viewport. - `max-width: calc(100vw - 24px)` — never overflows narrow viewports. - Eliminates horizontal scroll on the map page at 768/1024/1440/1920/2560. ### Modal box (`.modal`) - `max-height: 80vh → 90vh` (spec §3). - `width: min(90vw, 500px)` — fluid, drops to 90vw below 555px. - `position: relative` so sticky descendants anchor to the modal box. - `.modal-overlay` gets `padding: clamp(8px, 2vw, 24px)` for edge breathing room. ### BYOP modal sticky close - `.byop-header { position: sticky; top: 0 }` with `var(--card-bg)` backdrop and bottom border — the title bar + ✕ stay reachable while the body scrolls. - `.byop-x` restyled with border, hit area, hover state. ### Untouched (intentional) - `public/map.js` did not need changes — the `.map-controls` element is the only narrow-viewport offender; the markup stays identical. - Channel modals (`.ch-modal*`, `.ch-share-modal*`) already have their own width/max-width tokens from #1034/#1087 and are out of scope for this task. ## TDD - **Red commit** `b69e992`: `test-map-modal-fluid-e2e.js` asserts (a) no horizontal scroll on `/#/map` at 1024/1440/1920/2560, (b) `.map-controls` right edge inside viewport at 768px wide, (c) BYOP modal at 1024×768 has `height ≤ 90vh`, `overflow-y: auto|scroll`, and close button is `position: sticky` and reachable. All assertions fail against the previous CSS (fixed-width 220px controls overflow at narrow widths; modal max-height was 80vh, not 90vh; close button was `position: static`). - **Green commit** `3e6df9d`: CSS changes above; all assertions pass. ## E2E - Wired into `.github/workflows/deploy.yml` after the channel-1087 E2E: ``` BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js ``` ## Acceptance criteria - [x] Map controls do not overlap markers at narrow viewports (fluid clamp width + max-width). - [x] Map fills extra space on ultrawide (panel caps at 240px, leaflet flex:1 takes the rest — already true; controls no longer steal grow room). - [x] Modals: `max-height: 90vh`, internal scroll, sticky close button, max-width via `min()`. - [x] No modal can exceed viewport height at any tested width. - [x] Verified via E2E at 768/1024/1440/1920/2560. ## Out of scope (left for sibling tasks under #1050) - Tab bars / nav (Task 1050-1, blocker). - Filter bars and table chrome (other 1050-N tasks). --------- Co-authored-by: corescope-bot <bot@corescope.local>
276 lines
13 KiB
JavaScript
276 lines
13 KiB
JavaScript
/**
|
|
* E2E (#1059): Map controls + modal fluid/safe-max-height behavior.
|
|
*
|
|
* Strengthened per polish review (round 2):
|
|
* - MAJOR-1: assert .modal max-height is STRICTLY > 80vh (i.e. >= 90vh);
|
|
* reject 80vh by inspecting the computed pixel value.
|
|
* - MAJOR-2: behavioral sticky-close test — inflate modal body past viewport,
|
|
* scroll modal content to the bottom, assert close button still inside
|
|
* viewport AND clickable (elementFromPoint at its center returns the close
|
|
* button or its child).
|
|
* - MAJOR-3: inject 100 tall paragraphs into BYOP modal body to force the
|
|
* overflow scenario (otherwise the modal never grows past 90vh and the
|
|
* overflow path is never exercised).
|
|
* - MAJOR-4 AC1: at 768x900, inject a synthetic .leaflet-marker-icon at the
|
|
* top-right of the leaflet container (where map controls live) and assert
|
|
* no .map-controls element bounds overlap the marker bounds.
|
|
* - MAJOR-4 AC2: at 2560x1440, assert .leaflet-container width >= 2400px
|
|
* (map fills extra horizontal space on ultrawide).
|
|
* - MAJOR-5: viewports list includes 1080 (matches PR body).
|
|
*
|
|
* Usage: BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js
|
|
*/
|
|
'use strict';
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
|
|
|
let passed = 0, failed = 0;
|
|
async function step(name, fn) {
|
|
try { await fn(); passed++; console.log(' ✓ ' + name); }
|
|
catch (e) { failed++; console.error(' ✗ ' + 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 || undefined,
|
|
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
|
});
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(8000);
|
|
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
|
|
|
console.log(`\n=== #1059 map+modal fluid E2E (strengthened) against ${BASE} ===`);
|
|
|
|
// --- Map page: no horizontal scroll across viewports (incl. 1080 per PR body) ---
|
|
const viewports = [
|
|
{ w: 1024, h: 768 },
|
|
{ w: 1080, h: 800 }, // MAJOR-5: aligns with PR body claim
|
|
{ w: 1440, h: 900 },
|
|
{ w: 1920, h: 1080 },
|
|
{ w: 2560, h: 1440 },
|
|
];
|
|
for (const v of viewports) {
|
|
await step(`no horizontal scroll on /#/map at ${v.w}x${v.h}`, async () => {
|
|
await page.setViewportSize({ width: v.w, height: v.h });
|
|
await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#leaflet-map', { timeout: 8000 });
|
|
await page.waitForTimeout(300);
|
|
const overflow = await page.evaluate(() => ({
|
|
sw: document.documentElement.scrollWidth,
|
|
cw: document.documentElement.clientWidth,
|
|
}));
|
|
assert(overflow.sw <= overflow.cw + 1,
|
|
`horizontal scroll: scrollWidth=${overflow.sw} clientWidth=${overflow.cw}`);
|
|
});
|
|
}
|
|
|
|
// --- MAJOR-4 AC1: at 768x900, controls do NOT overlap marker bounds ---
|
|
await step('AC1: map controls do not overlap marker at 768x900', async () => {
|
|
await page.setViewportSize({ width: 768, height: 900 });
|
|
await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#leaflet-map', { timeout: 8000 });
|
|
await page.waitForSelector('.map-controls', { timeout: 8000 });
|
|
await page.waitForTimeout(400);
|
|
// Inject a synthetic marker in the LEFT half of the leaflet container.
|
|
// The controls panel sits in the top-right corner; if it grows
|
|
// uncontrolled (e.g. fixed 220px+ at narrow viewports) or wraps into
|
|
// the map area, it can overlap markers placed away from the corner.
|
|
// We assert controls DO NOT bleed across the centerline into a marker
|
|
// sitting at left:50%, top:80px.
|
|
const result = await page.evaluate(() => {
|
|
const lc = document.querySelector('.leaflet-container');
|
|
if (!lc) return { ok: false, reason: 'no .leaflet-container' };
|
|
const lr = lc.getBoundingClientRect();
|
|
const m = document.createElement('div');
|
|
m.className = 'leaflet-marker-icon test-marker-1059';
|
|
// Marker centered horizontally inside leaflet, at top:80px.
|
|
const left = lr.left + (lr.width / 2) - 12;
|
|
m.style.cssText = 'position:absolute;width:24px;height:24px;' +
|
|
'left:' + left + 'px;top:' + (lr.top + 80) + 'px;' +
|
|
'background:red;z-index:399;pointer-events:none;';
|
|
document.body.appendChild(m);
|
|
const mb = m.getBoundingClientRect();
|
|
const ctrls = Array.from(document.querySelectorAll('.map-controls'));
|
|
const overlaps = ctrls.map((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
const overlap = !(r.right <= mb.left || r.left >= mb.right ||
|
|
r.bottom <= mb.top || r.top >= mb.bottom);
|
|
return { overlap, ctrl: { l: r.left, r: r.right, t: r.top, b: r.bottom, w: r.width } };
|
|
});
|
|
return { ok: true, marker: { l: mb.left, r: mb.right, t: mb.top, b: mb.bottom }, overlaps, vw: window.innerWidth };
|
|
});
|
|
assert(result.ok, result.reason || 'setup failed');
|
|
const overlapping = result.overlaps.filter((o) => o.overlap);
|
|
assert(overlapping.length === 0,
|
|
`map controls overlap centered marker (controls bled across viewport): ` +
|
|
`marker=${JSON.stringify(result.marker)} overlapping=${JSON.stringify(overlapping)} ` +
|
|
`vw=${result.vw}`);
|
|
});
|
|
|
|
// --- MAJOR-4 AC2: at 2560x1440, leaflet-container fills extra space ---
|
|
await step('AC2: leaflet-container width >= 2400px at 2560x1440', async () => {
|
|
await page.setViewportSize({ width: 2560, height: 1440 });
|
|
await page.goto(BASE + '/#/map', { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.leaflet-container', { timeout: 8000 });
|
|
await page.waitForTimeout(300);
|
|
const w = await page.evaluate(() => {
|
|
const lc = document.querySelector('.leaflet-container');
|
|
return lc ? lc.getBoundingClientRect().width : 0;
|
|
});
|
|
assert(w >= 2400,
|
|
`leaflet-container width ${w} < 2400px (map not filling ultrawide)`);
|
|
});
|
|
|
|
// --- MAJOR-1 + 2 + 3: BYOP modal — strict 90vh, inflated content, sticky close ---
|
|
// Helper: open BYOP modal cleanly. Close any existing modal first since
|
|
// hash-route navigation does not reload the SPA and would leave a previous
|
|
// modal open.
|
|
async function openByopModal(viewport) {
|
|
await page.setViewportSize(viewport);
|
|
// Force a real reload to clear any modal/state from the previous step.
|
|
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('[data-action="pkt-byop"]', { timeout: 8000 });
|
|
// Defensive: dismiss any pre-existing overlay.
|
|
await page.evaluate(() => {
|
|
document.querySelectorAll('.byop-overlay, .modal-overlay').forEach((el) => el.remove());
|
|
});
|
|
await page.click('[data-action="pkt-byop"]');
|
|
await page.waitForSelector('.byop-modal', { timeout: 5000 });
|
|
}
|
|
|
|
await step('BYOP modal: max-height >= 90vh STRICT (rejects 80vh)', async () => {
|
|
await openByopModal({ width: 1024, height: 800 });
|
|
const m = await page.evaluate(() => {
|
|
const modal = document.querySelector('.byop-modal');
|
|
const cs = getComputedStyle(modal);
|
|
return {
|
|
vh: window.innerHeight,
|
|
maxHeightPx: parseFloat(cs.maxHeight),
|
|
rawMaxHeight: cs.maxHeight,
|
|
};
|
|
});
|
|
// STRICT: max-height in pixels must be >= 90% of viewport height.
|
|
// 80vh would be 0.80 * vh ≈ 640 at vh=800. 90vh ≈ 720.
|
|
const eightyVh = m.vh * 0.80;
|
|
const ninetyVh = m.vh * 0.90;
|
|
assert(m.maxHeightPx >= ninetyVh - 1,
|
|
`modal max-height ${m.maxHeightPx}px < 90vh (${ninetyVh}px). raw=${m.rawMaxHeight}`);
|
|
// NEGATIVE: 80vh must NOT be acceptable. If max-height equals 80vh, fail.
|
|
assert(m.maxHeightPx > eightyVh + 4,
|
|
`modal max-height ${m.maxHeightPx}px is at or below 80vh (${eightyVh}px). ` +
|
|
`Spec requires > 80vh. raw=${m.rawMaxHeight}`);
|
|
});
|
|
|
|
await step('BYOP modal: inflated content overflows internally (90vh cap holds)', async () => {
|
|
await openByopModal({ width: 1024, height: 800 });
|
|
// MAJOR-3: inject 100 tall paragraphs INSIDE the modal so content >> 90vh.
|
|
await page.evaluate(() => {
|
|
const modal = document.querySelector('.byop-modal');
|
|
const filler = document.createElement('div');
|
|
filler.id = 'byop-overflow-filler-1059';
|
|
let html = '';
|
|
for (let i = 0; i < 100; i++) {
|
|
html += '<p style="margin:0 0 12px;line-height:1.6;font-size:14px;">' +
|
|
'Filler paragraph ' + i + ' — lorem ipsum dolor sit amet, consectetur ' +
|
|
'adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore ' +
|
|
'magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.</p>';
|
|
}
|
|
filler.innerHTML = html;
|
|
modal.appendChild(filler);
|
|
});
|
|
await page.waitForTimeout(150);
|
|
const m = await page.evaluate(() => {
|
|
const modal = document.querySelector('.byop-modal');
|
|
const r = modal.getBoundingClientRect();
|
|
const cs = getComputedStyle(modal);
|
|
return {
|
|
vh: window.innerHeight,
|
|
modalH: r.height,
|
|
scrollH: modal.scrollHeight,
|
|
clientH: modal.clientHeight,
|
|
overflowY: cs.overflowY,
|
|
};
|
|
});
|
|
// Modal box must NOT exceed 90vh even though content is huge.
|
|
assert(m.modalH <= m.vh * 0.90 + 2,
|
|
`modal height ${m.modalH} > 90vh of ${m.vh}=${m.vh * 0.90}`);
|
|
// Content must actually overflow internally (proves overflow path is exercised).
|
|
assert(m.scrollH > m.clientH + 50,
|
|
`modal content did not overflow: scrollHeight=${m.scrollH} clientHeight=${m.clientH}`);
|
|
// Internal scroll must be auto/scroll, not visible/hidden.
|
|
assert(m.overflowY === 'auto' || m.overflowY === 'scroll',
|
|
`modal overflow-y must be auto/scroll under overflow, got ${m.overflowY}`);
|
|
});
|
|
|
|
await step('BYOP modal: close button reachable AFTER scrolling past it (behavioral)', async () => {
|
|
await openByopModal({ width: 1024, height: 800 });
|
|
// Inflate content so modal scrolls.
|
|
await page.evaluate(() => {
|
|
const modal = document.querySelector('.byop-modal');
|
|
const filler = document.createElement('div');
|
|
let html = '';
|
|
for (let i = 0; i < 100; i++) {
|
|
html += '<p style="margin:0 0 12px;line-height:1.6;font-size:14px;">' +
|
|
'Filler ' + i + ' — lorem ipsum dolor sit amet.</p>';
|
|
}
|
|
filler.innerHTML = html;
|
|
modal.appendChild(filler);
|
|
});
|
|
await page.waitForTimeout(150);
|
|
// Capture initial close-button position.
|
|
const initialClose = await page.evaluate(() => {
|
|
const c = document.querySelector('.byop-modal .byop-x');
|
|
const r = c.getBoundingClientRect();
|
|
return { top: r.top, bottom: r.bottom, vh: window.innerHeight };
|
|
});
|
|
// Scroll modal content to the bottom.
|
|
await page.evaluate(() => {
|
|
const modal = document.querySelector('.byop-modal');
|
|
modal.scrollTop = modal.scrollHeight;
|
|
});
|
|
await page.waitForTimeout(150);
|
|
const m = await page.evaluate(() => {
|
|
const modal = document.querySelector('.byop-modal');
|
|
const close = document.querySelector('.byop-modal .byop-x');
|
|
const cr = close.getBoundingClientRect();
|
|
const cx = cr.left + cr.width / 2;
|
|
const cy = cr.top + cr.height / 2;
|
|
const hit = document.elementFromPoint(cx, cy);
|
|
const inViewport = cr.top >= 0 && cr.bottom <= window.innerHeight + 1;
|
|
// hit should be the close button itself or a descendant of it, NOT
|
|
// some scrolled-past content. Walk up from hit to find close.
|
|
let n = hit, isCloseOrChild = false;
|
|
for (let i = 0; n && i < 8; i++) {
|
|
if (n === close) { isCloseOrChild = true; break; }
|
|
n = n.parentElement;
|
|
}
|
|
return {
|
|
scrollTop: modal.scrollTop,
|
|
scrollMax: modal.scrollHeight - modal.clientHeight,
|
|
closeTop: cr.top, closeBottom: cr.bottom, vh: window.innerHeight,
|
|
inViewport, isCloseOrChild,
|
|
hitTag: hit ? hit.tagName + '.' + (hit.className || '') : 'null',
|
|
};
|
|
});
|
|
// Sanity: we actually scrolled.
|
|
assert(m.scrollTop > 50,
|
|
`modal did not scroll: scrollTop=${m.scrollTop} scrollMax=${m.scrollMax}`);
|
|
// BEHAVIORAL: close button still inside viewport after scrolling content.
|
|
assert(m.inViewport,
|
|
`close button left viewport after scroll: top=${m.closeTop} bottom=${m.closeBottom} vh=${m.vh} ` +
|
|
`(initial top=${initialClose.top}); means close is NOT sticky`);
|
|
// BEHAVIORAL: close button is hit-testable (no overlay covers it).
|
|
assert(m.isCloseOrChild,
|
|
`elementFromPoint at close-button center returned ${m.hitTag}, not the close button`);
|
|
});
|
|
|
|
await browser.close();
|
|
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
|
process.exit(failed === 0 ? 0 : 1);
|
|
})();
|