Files
meshcore-analyzer/test-map-modal-fluid-e2e.js
T
Kpa-clawbot b52a938b27 fix(#1059): map controls + modals — fluid + safe max-height (#1096)
## 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>
2026-05-05 08:10:11 -07:00

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);
})();