riprap-nyc / web /sveltekit /tests /e2e /sticky-map.spec.ts
seriffic's picture
Frontend overhaul: Lit kickoff → Svelte 5 custom elements → SvelteKit design-system
e8a6c67
/**
* Sticky-map vs trace overlap regression.
*
* Per handoff hard rule #12, the map sticks at top: 80px with a viewport
* cap. The bug: with the original single-grid `.app-shell` layout, the
* map's sticky containing block spanned the whole grid (including the
* evidence and trace rows below) — so when the user scrolled to the
* trace, the map kept sticking and visually overlapped the trace UI.
*
* Fix: split `.app-shell` into a sticky-parent top region (brief + map +
* cites) and a non-sticky bottom region (evidence + trace). This test
* scrolls to the trace and asserts the map's rect doesn't intersect.
*/
import { test, expect } from '@playwright/test';
test.describe('sticky map containment', () => {
test('does not overlap the trace UI when scrolled to bottom', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/q/sample');
// Wait for the map to mount so its bounding box is meaningful.
await page.waitForFunction(
() => Boolean((window as unknown as { __riprapMap?: unknown }).__riprapMap),
undefined,
{ timeout: 15_000 }
);
// Scroll the trace into view and let layout settle.
await page.locator('#region-trace').scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const rects = await page.evaluate(() => {
const m = document.getElementById('region-map')?.getBoundingClientRect();
const t = document.getElementById('region-trace')?.getBoundingClientRect();
const e = document.querySelector('[aria-label="Evidence"]')?.getBoundingClientRect();
return { m, t, e };
});
expect(rects.m, 'map rect').toBeTruthy();
expect(rects.t, 'trace rect').toBeTruthy();
if (!rects.m || !rects.t) return;
// Two boxes overlap iff X-axis ranges overlap AND Y-axis ranges overlap.
const xOverlap = !(rects.m.right <= rects.t.left || rects.t.right <= rects.m.left);
const yOverlap = !(rects.m.bottom <= rects.t.top || rects.t.bottom <= rects.m.top);
const overlaps = xOverlap && yOverlap;
expect(overlaps,
`map should not visually overlap the trace UI when scrolled to it. ` +
`Map rect: ${JSON.stringify(rects.m)}; Trace rect: ${JSON.stringify(rects.t)}.`
).toBe(false);
// Belt + suspenders: map's bottom edge must be ABOVE the trace's top.
expect(rects.m.bottom).toBeLessThanOrEqual(rects.t.top + 1);
});
test('side rail is position:sticky with citations stacked beneath the map (DOM order)', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/q/sample');
await page.waitForFunction(
() => Boolean((window as unknown as { __riprapMap?: unknown }).__riprapMap),
undefined,
{ timeout: 15_000 }
);
// Note: we don't pin a specific viewport-top here. Sample is a
// short page so the sticky range is small; live briefings (where
// brief content is much longer) get a multi-screen sticky range
// automatically because .app-shell-top stretches with brief height.
// What we verify here is the structural contract: rail is sticky
// at top:80, the map renders before citations in the rail, and the
// rail itself can scroll internally.
const facts = await page.evaluate(() => {
const rail = document.querySelector('.app-region-side') as HTMLElement | null;
if (!rail) return null;
const cs = getComputedStyle(rail);
const map = document.getElementById('region-map');
const cites = document.querySelector('.citation-drawer');
const order = map && cites &&
(map.compareDocumentPosition(cites) & Node.DOCUMENT_POSITION_FOLLOWING) ? 'map-first' : 'wrong';
return {
position: cs.position,
top: cs.top,
overflowY: cs.overflowY,
order
};
});
expect(facts).toBeTruthy();
if (!facts) return;
expect(facts.position).toBe('sticky');
expect(facts.top).toBe('80px');
expect(facts.overflowY).toBe('auto');
expect(facts.order).toBe('map-first');
});
test('citations are reachable from the side rail (not buried under sticky map)', async ({ page }) => {
// Regression: at 100% browser zoom the map's max-height was nearly
// full viewport, so the citation drawer ended up entirely behind
// the sticky map after any scroll. Fix: wrap map + citations in a
// single sticky overflow-y:auto rail so the user can always scroll
// to the citations from any page-scroll position.
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/q/sample');
await page.waitForFunction(
() => Boolean((window as unknown as { __riprapMap?: unknown }).__riprapMap),
undefined,
{ timeout: 15_000 }
);
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'instant' }));
await page.waitForTimeout(250);
// The side rail itself is sticky and overflow-y:auto — citations
// are always inside it, reachable by scrolling within the rail.
const inside = await page.evaluate(() => {
const rail = document.querySelector('.app-region-side') as HTMLElement | null;
const cites = document.querySelector('.citation-drawer') as HTMLElement | null;
if (!rail || !cites) return null;
const inside = rail.contains(cites);
const railRect = rail.getBoundingClientRect();
const railVisible = railRect.top < window.innerHeight && railRect.bottom > 0;
const railScrollable = rail.scrollHeight > rail.clientHeight;
// Map sits at the start of the flex column; citations come after
// it in DOM order (so internal-scrolling reveals them).
const map = document.getElementById('region-map');
const mapBeforeCites =
map &&
map.compareDocumentPosition(cites) & Node.DOCUMENT_POSITION_FOLLOWING;
return { inside, railVisible, railScrollable, mapBeforeCites: Boolean(mapBeforeCites) };
});
expect(inside, 'rail / cites geometry').toBeTruthy();
if (!inside) return;
expect(inside.inside, 'citations inside side rail').toBe(true);
expect(inside.railVisible, 'side rail visible in viewport').toBe(true);
expect(inside.mapBeforeCites, 'map renders before cites in DOM').toBe(true);
});
});