fix: make nightdrive signage reliable and legible
This commit is contained in:
@@ -198,10 +198,18 @@ test("explicit keyboard navigation focuses a section heading without a fixed-sur
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
|
||||
const geometry = await currentFocusGeometry(page);
|
||||
expect(geometry.focusVisible).toBe(true);
|
||||
expect(geometry.insideSafeBounds).toBe(true);
|
||||
expect(geometry.overlapArea).toBe(0);
|
||||
await expect.poll(async () => {
|
||||
const geometry = await currentFocusGeometry(page);
|
||||
return {
|
||||
focusVisible: geometry.focusVisible,
|
||||
insideSafeBounds: geometry.insideSafeBounds,
|
||||
overlapArea: geometry.overlapArea,
|
||||
};
|
||||
}).toEqual({
|
||||
focusVisible: true,
|
||||
insideSafeBounds: true,
|
||||
overlapArea: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("responsive navigation exposes the exact visible label as each link's accessible name", async ({
|
||||
|
||||
@@ -83,14 +83,19 @@ test('draws only the independently eligible next roadside sign during passive fo
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
const oracle = await page.evaluate(() => {
|
||||
const viewport = page.viewportSize();
|
||||
if (viewport === null) throw new Error('Missing browser viewport');
|
||||
const oracle = await page.evaluate((candidateDistanceVh) => {
|
||||
const profile = document.getElementById('profile-heading');
|
||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
||||
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.45));
|
||||
const destination = Math.max(
|
||||
1,
|
||||
headingTop - viewportHeight * (0.35 + candidateDistanceVh),
|
||||
);
|
||||
return { destination, viewportHeight, expectedLabel: 'Profile' };
|
||||
});
|
||||
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
|
||||
for (let y = 1; y < oracle.destination; y += oracle.viewportHeight * 0.5) {
|
||||
await page.evaluate((nextY) => window.scrollTo(0, nextY), y);
|
||||
@@ -134,6 +139,21 @@ test('keeps a readable sign immutable through reversal, resize, and a multi-sect
|
||||
|
||||
await page.mouse.wheel(0, -120);
|
||||
await page.setViewportSize({ width: 844, height: 390 });
|
||||
await page.waitForTimeout(500);
|
||||
const resizedFrame = (await readSceneInstrumentation(page)).signFrames.findLast((frame) => (
|
||||
frame.targetId === 'profile'
|
||||
&& frame.viewport.width === 844
|
||||
&& frame.viewport.height === 390
|
||||
));
|
||||
if (resizedFrame === undefined) throw new Error('Missing post-resize Profile frame');
|
||||
expect(resizedFrame.phase).toBe('dwell');
|
||||
expect(resizedFrame.viewportProfile).toBe('short');
|
||||
expect(resizedFrame.bounds.left).toBeGreaterThanOrEqual(8);
|
||||
expect(resizedFrame.bounds.right).toBeLessThanOrEqual(288);
|
||||
expect(countSignPresentations(
|
||||
(await readSceneInstrumentation(page)).signFrames,
|
||||
'profile',
|
||||
)).toBe(1);
|
||||
await page.evaluate(() => {
|
||||
const target = document.getElementById('selected-builds-heading');
|
||||
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
|
||||
@@ -146,7 +166,57 @@ test('keeps a readable sign immutable through reversal, resize, and a multi-sect
|
||||
expect((await readSceneInstrumentation(page)).maximumCanvasCount).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('suppresses roadside signs for explicit navigation and history restoration', async ({
|
||||
test('carries a departed sign past the car before its restrained edge fade', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1_440, height: 900 });
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
const viewport = page.viewportSize();
|
||||
if (viewport === null) throw new Error('Missing browser viewport');
|
||||
const destination = await page.evaluate((candidateDistanceVh) => {
|
||||
const profile = document.getElementById('profile-heading');
|
||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.max(
|
||||
1,
|
||||
profile.getBoundingClientRect().top + window.scrollY
|
||||
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||
);
|
||||
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||
await page.mouse.wheel(0, 1);
|
||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.some((frame) => (
|
||||
frame.targetId === 'profile'
|
||||
&& frame.phase === 'depart'
|
||||
&& frame.progress >= 0.52
|
||||
)), { intervals: [16], timeout: 5_000 }).toBe(true);
|
||||
|
||||
const frames = (await readSceneInstrumentation(page)).signFrames;
|
||||
const dwell = frames.findLast((frame) => frame.targetId === 'profile' && frame.phase === 'dwell');
|
||||
const passing = frames.findLast((frame) => (
|
||||
frame.targetId === 'profile'
|
||||
&& frame.phase === 'depart'
|
||||
&& frame.progress >= 0.3
|
||||
&& frame.opacity >= 0.98
|
||||
));
|
||||
if (dwell === undefined || passing === undefined) {
|
||||
throw new Error('Missing comparable Profile dwell and pass-by frames');
|
||||
}
|
||||
expect(passing.opacity).toBeGreaterThanOrEqual(0.98);
|
||||
expect(passing.bounds.width).toBeGreaterThan(dwell.bounds.width * 1.18);
|
||||
expect(passing.bounds.left).toBeLessThan(dwell.bounds.left - 24);
|
||||
expect(passing.targetId).toBe(dwell.targetId);
|
||||
});
|
||||
|
||||
test('shows exactly one destination sign for a navigator click and suppresses history restoration', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
@@ -155,15 +225,168 @@ test('suppresses roadside signs for explicit navigation and history restoration'
|
||||
|
||||
await clickNavigationLink(page, '#selected-builds');
|
||||
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
||||
await page.waitForTimeout(400);
|
||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.findLast((frame) => (
|
||||
frame.targetId === 'selected-builds'
|
||||
))?.phase).toBe('dwell');
|
||||
|
||||
await clickNavigationLink(page, '#selected-builds');
|
||||
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
||||
await page.waitForTimeout(2_400);
|
||||
const afterRepeat = await readSceneInstrumentation(page);
|
||||
expect(afterRepeat.routeLabelsDrawn).toEqual(['Selected builds']);
|
||||
expect(countSignPresentations(afterRepeat.signFrames, 'selected-builds')).toBe(1);
|
||||
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(400);
|
||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual(['Selected builds']);
|
||||
});
|
||||
|
||||
test('restores the next sign after a navigator round trip', async ({
|
||||
test('keeps the non-navigator Explore work section jump sign-suppressed', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
await page.locator('.hero-journey__action').click();
|
||||
await expect(page.locator('#selected-builds-heading')).toBeFocused();
|
||||
await page.waitForTimeout(400);
|
||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
||||
|
||||
const viewport = page.viewportSize();
|
||||
if (viewport === null) throw new Error('Missing browser viewport');
|
||||
const destination = await page.evaluate((candidateDistanceVh) => {
|
||||
const heading = document.getElementById('additional-work-heading');
|
||||
if (!(heading instanceof HTMLElement)) throw new Error('Missing Additional work heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.max(
|
||||
1,
|
||||
heading.getBoundingClientRect().top + window.scrollY
|
||||
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||
);
|
||||
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||
await page.mouse.wheel(0, 1);
|
||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.findLast((frame) => frame.targetId === 'additional-work')?.phase).toBe('dwell');
|
||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual(['Additional work']);
|
||||
});
|
||||
|
||||
test('keeps a navigator-triggered Profile sign fully beside its visible destination panel', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 780, height: 493 });
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
await clickNavigationLink(page, '#profile');
|
||||
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.some((frame) => (
|
||||
frame.targetId === 'profile' && frame.phase === 'dwell'
|
||||
))).toBe(true);
|
||||
|
||||
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
||||
));
|
||||
if (frame === undefined) throw new Error('Missing navigator-triggered Profile dwell frame');
|
||||
const panel = await page.locator('#profile').evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
|
||||
});
|
||||
expect(frame.bounds.right).toBeLessThanOrEqual(panel.left - 16);
|
||||
expect(frame.bounds.left).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test('keeps a portrait navigator sign clear of its full-width destination panel', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact portrait composition is sufficient.');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
await clickNavigationLink(page, '#profile');
|
||||
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
||||
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
||||
));
|
||||
if (frame === undefined) throw new Error('Missing portrait navigator Profile dwell frame');
|
||||
const panels = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
||||
elements.map((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
id: element.id,
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
right: bounds.right,
|
||||
bottom: bounds.bottom,
|
||||
};
|
||||
})
|
||||
));
|
||||
for (const panel of panels) {
|
||||
expect(
|
||||
rectanglesOverlap(frame.bounds, panel, 16),
|
||||
JSON.stringify({ frame: frame.bounds, panel }),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('moves an already-readable portrait sign into navigator-safe placement without replaying it', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact portrait composition is sufficient.');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
const destination = await page.evaluate(() => {
|
||||
const profile = document.getElementById('profile-heading');
|
||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing Profile heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.max(
|
||||
1,
|
||||
profile.getBoundingClientRect().top + window.scrollY
|
||||
- viewportHeight * (0.35 + 0.22),
|
||||
);
|
||||
});
|
||||
await page.mouse.wheel(0, 1);
|
||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.findLast((frame) => frame.targetId === 'profile')?.phase).toBe('dwell');
|
||||
|
||||
await clickNavigationLink(page, '#profile');
|
||||
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||
const panel = await page.locator('#profile').evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
|
||||
});
|
||||
await expect.poll(async () => {
|
||||
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||
candidate.targetId === 'profile'
|
||||
&& candidate.phase === 'dwell'
|
||||
&& candidate.navigationArrival === true
|
||||
));
|
||||
return frame === undefined ? true : rectanglesOverlap(frame.bounds, panel, 16);
|
||||
}, { intervals: [16], timeout: 5_000 }).toBe(false);
|
||||
|
||||
const instrumentation = await readSceneInstrumentation(page);
|
||||
expect(countSignPresentations(instrumentation.signFrames, 'profile')).toBe(1);
|
||||
expect(instrumentation.routeLabelsDrawn).toEqual(['Profile']);
|
||||
});
|
||||
|
||||
test('re-arms passive signage after a navigator round trip to Start', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
@@ -172,31 +395,39 @@ test('restores the next sign after a navigator round trip', async ({
|
||||
|
||||
await clickNavigationLink(page, '#profile');
|
||||
await expect(page.locator('#profile-heading')).toBeFocused();
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
||||
await page.waitForTimeout(2_500);
|
||||
await clickNavigationLink(page, '#start');
|
||||
await expect(page.locator('#start-heading')).toBeFocused();
|
||||
|
||||
const destination = await page.evaluate(() => {
|
||||
const viewport = page.viewportSize();
|
||||
if (viewport === null) throw new Error('Missing browser viewport');
|
||||
const destination = await page.evaluate((candidateDistanceVh) => {
|
||||
const profile = document.getElementById('profile-heading');
|
||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.max(
|
||||
1,
|
||||
profile.getBoundingClientRect().top + window.scrollY
|
||||
- viewportHeight * (0.35 + 0.45),
|
||||
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||
);
|
||||
});
|
||||
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||
await page.mouse.wheel(0, 1);
|
||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.some((frame) => frame.targetId === 'profile')).toBe(true);
|
||||
await expect.poll(async () => countSignPresentations(
|
||||
(await readSceneInstrumentation(page)).signFrames,
|
||||
'profile',
|
||||
)).toBe(2);
|
||||
});
|
||||
|
||||
test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1_440, height: 900 });
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
|
||||
@@ -211,8 +442,20 @@ test('reconciles an idle rapid jump to the latest active route without flashing
|
||||
|
||||
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
|
||||
.toContain('Selected builds');
|
||||
expect([...new Set((await readSceneInstrumentation(page)).routeLabelsDrawn)])
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.findLast((frame) => (
|
||||
frame.targetId === 'selected-builds'
|
||||
))?.phase, { intervals: [16], timeout: 5_000 }).toBe('dwell');
|
||||
const instrumentation = await readSceneInstrumentation(page);
|
||||
expect([...new Set(instrumentation.routeLabelsDrawn)])
|
||||
.toEqual(['Selected builds']);
|
||||
const dwell = instrumentation.signFrames.findLast((frame) => (
|
||||
frame.targetId === 'selected-builds' && frame.phase === 'dwell'
|
||||
));
|
||||
if (dwell === undefined) throw new Error('Missing reconciled Selected builds dwell frame');
|
||||
expect(dwell.bounds.left).toBeGreaterThanOrEqual(8);
|
||||
expect(dwell.bounds.right).toBeLessThanOrEqual(1_432);
|
||||
});
|
||||
|
||||
test('keeps the readable Profile face outside its destination panel with verified texel density', async ({
|
||||
@@ -267,6 +510,7 @@ test('keeps a fresh readable sign inside responsive world-safe corridors', async
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 390, height: 844, profile: 'tall', minimumFaceWidth: 120 },
|
||||
{ width: 780, height: 493, profile: 'short', minimumFaceWidth: 120 },
|
||||
{ width: 844, height: 390, profile: 'short', minimumFaceWidth: 130 },
|
||||
{ width: 768, height: 1_024, profile: 'tall', minimumFaceWidth: 150 },
|
||||
{ width: 1_440, height: 900, profile: 'wide', minimumFaceWidth: 180 },
|
||||
@@ -275,16 +519,16 @@ test('keeps a fresh readable sign inside responsive world-safe corridors', async
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
const destination = await page.evaluate(() => {
|
||||
const destination = await page.evaluate((candidateDistanceVh) => {
|
||||
const profile = document.getElementById('profile-heading');
|
||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.max(
|
||||
1,
|
||||
profile.getBoundingClientRect().top + window.scrollY
|
||||
- viewportHeight * (0.35 + 0.45),
|
||||
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||
);
|
||||
});
|
||||
}, triggerProbeDistanceVh(viewport.width, viewport.height));
|
||||
await page.mouse.wheel(0, 1);
|
||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||
await expect.poll(async () => (
|
||||
@@ -332,6 +576,159 @@ test('keeps a fresh readable sign inside responsive world-safe corridors', async
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the upcoming Experience sign clear of both outgoing and incoming panels', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact desktop composition is sufficient.');
|
||||
await page.setViewportSize({ width: 1_440, height: 900 });
|
||||
await page.goto(`${distUrl}/#profile`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
await expect(page.locator('nav a[href="#profile"]')).toHaveAttribute('aria-current', 'page');
|
||||
const destination = await page.evaluate(() => {
|
||||
const heading = document.getElementById('experience-heading');
|
||||
if (!(heading instanceof HTMLElement)) throw new Error('Missing Experience heading');
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.max(
|
||||
1,
|
||||
heading.getBoundingClientRect().top + window.scrollY
|
||||
- viewportHeight * (0.35 + 0.45),
|
||||
);
|
||||
});
|
||||
await page.mouse.wheel(0, 1);
|
||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.at(-1)?.phase, { intervals: [16], timeout: 5_000 }).toBe('dwell');
|
||||
|
||||
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||
candidate.targetId === 'experience' && candidate.phase === 'dwell'
|
||||
));
|
||||
if (frame === undefined) throw new Error('Missing readable Experience sign frame');
|
||||
const panels = await page.locator('#profile, #experience').evaluateAll((elements) => (
|
||||
elements.map((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
id: element.id,
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
right: bounds.right,
|
||||
bottom: bounds.bottom,
|
||||
};
|
||||
}).filter((bounds) => bounds.bottom > 0 && bounds.top < window.innerHeight)
|
||||
));
|
||||
expect(panels.map(({ id }) => id)).toEqual(['profile', 'experience']);
|
||||
for (const panel of panels) {
|
||||
expect(rectanglesOverlap(frame.bounds, panel, 16), panel.id).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps every passive transition sign clear of visible opaque panels and fixed controls', async ({
|
||||
page,
|
||||
distUrl,
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact responsive transition matrix is sufficient.');
|
||||
test.setTimeout(180_000);
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 1_440, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 844, height: 390 },
|
||||
] as const) {
|
||||
await page.setViewportSize(viewport);
|
||||
for (let index = 1; index < sections.length; index += 1) {
|
||||
const previous = sections[index - 1];
|
||||
const next = sections[index];
|
||||
if (previous === undefined || next === undefined) throw new Error('Incomplete route transition');
|
||||
const startUrl = previous.id === 'start' ? distUrl : `${distUrl}/#${previous.id}`;
|
||||
await page.goto(startUrl, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||
await expect(page.locator(`nav a[href="#${previous.id}"]`)).toHaveAttribute('aria-current', 'page');
|
||||
const transition = `${viewport.width}x${viewport.height} ${previous.id} -> ${next.id}`;
|
||||
|
||||
const destination = await page.evaluate(({ targetId, candidateDistanceVh }) => {
|
||||
const heading = document.getElementById(`${targetId}-heading`);
|
||||
if (!(heading instanceof HTMLElement)) throw new Error(`Missing ${targetId} heading`);
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||
return Math.max(
|
||||
1,
|
||||
heading.getBoundingClientRect().top + window.scrollY
|
||||
- viewportHeight * (0.35 + candidateDistanceVh),
|
||||
);
|
||||
}, {
|
||||
targetId: next.id,
|
||||
candidateDistanceVh: viewport.width < viewport.height
|
||||
? 0.225
|
||||
: viewport.height < 560 ? 0.38 : 0.45,
|
||||
});
|
||||
await page.mouse.wheel(0, 1);
|
||||
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||
await expect.poll(async () => (
|
||||
await readSceneInstrumentation(page)
|
||||
).signFrames.findLast((frame) => frame.targetId === next.id)?.phase, {
|
||||
intervals: [16],
|
||||
message: `${transition} should reach readable dwell`,
|
||||
timeout: 5_000,
|
||||
}).toBe('dwell');
|
||||
|
||||
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||
candidate.targetId === next.id && candidate.phase === 'dwell'
|
||||
));
|
||||
if (frame === undefined) throw new Error(`Missing readable ${next.id} sign frame`);
|
||||
const occupied = await page.evaluate(() => {
|
||||
const alpha = (color: string): number => {
|
||||
const match = color.match(/rgba?\([^)]*(?:,|\s\/\s)([\d.]+)\)$/u);
|
||||
return match?.[1] === undefined ? 1 : Number.parseFloat(match[1]);
|
||||
};
|
||||
const boundsFor = (element: Element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
id: element.id || element.closest('[id]')?.id || element.className,
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
right: bounds.right,
|
||||
bottom: bounds.bottom,
|
||||
};
|
||||
};
|
||||
const panels = [...document.querySelectorAll(
|
||||
[
|
||||
'.hero-journey__content',
|
||||
'.portfolio-section:not([data-layout="full-diagram"]):not([data-layout="project-stage"])',
|
||||
'.portfolio-section[data-layout="full-diagram"] > h2',
|
||||
'.infrastructure-case > h3',
|
||||
'.infrastructure-case > p',
|
||||
'.infrastructure-flow-figure',
|
||||
'.portfolio-section[data-layout="project-stage"] > h2',
|
||||
'.portfolio-section[data-layout="project-stage"] > .section-eyebrow',
|
||||
'.project-case-study',
|
||||
'.project-card',
|
||||
].join(', '),
|
||||
)].filter((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const painted = style.backgroundImage !== 'none' || alpha(style.backgroundColor) > 0.08;
|
||||
return painted && bounds.bottom > 0 && bounds.top < window.innerHeight;
|
||||
}).map(boundsFor);
|
||||
const fixed = [...document.querySelectorAll(
|
||||
'.site-navigation, .drive-controls, .journey-hud',
|
||||
)].map(boundsFor);
|
||||
return { panels, fixed };
|
||||
});
|
||||
const panelOverlaps = occupied.panels
|
||||
.filter((panel) => rectanglesOverlap(frame.bounds, panel, 16))
|
||||
.map((panel) => ({ panel, sign: frame.bounds }));
|
||||
const fixedOverlaps = occupied.fixed
|
||||
.filter((zone) => rectanglesOverlap(frame.bounds, zone, 8));
|
||||
expect(frame.bounds.left, `${next.id} left edge`).toBeGreaterThanOrEqual(8);
|
||||
expect(frame.bounds.right, `${next.id} right edge`).toBeLessThanOrEqual(viewport.width - 8);
|
||||
expect(frame.bounds.top, `${next.id} top edge`).toBeGreaterThanOrEqual(8);
|
||||
expect(frame.bounds.bottom, `${next.id} bottom edge`).toBeLessThanOrEqual(viewport.height - 8);
|
||||
expect(panelOverlaps, transition).toEqual([]);
|
||||
expect(fixedOverlaps, transition).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function colorEnergy(color: string): number {
|
||||
const value = Number.parseInt(color.slice(1), 16);
|
||||
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
|
||||
@@ -349,3 +746,24 @@ function rectanglesOverlap(
|
||||
|| first.top >= second.bottom + gap
|
||||
);
|
||||
}
|
||||
|
||||
function countSignPresentations(
|
||||
frames: ReadonlyArray<{ targetId: string; phase: string }>,
|
||||
targetId: string,
|
||||
): number {
|
||||
let presentations = 0;
|
||||
let previousPhase: string | null = null;
|
||||
for (const frame of frames) {
|
||||
if (frame.targetId !== targetId) continue;
|
||||
if (frame.phase === 'anticipate' && previousPhase !== 'anticipate') presentations += 1;
|
||||
previousPhase = frame.phase;
|
||||
}
|
||||
return presentations;
|
||||
}
|
||||
|
||||
function triggerProbeDistanceVh(width: number, height: number): number {
|
||||
const aspect = width / height;
|
||||
if (height < 560 && aspect >= 1) return 0.37;
|
||||
if (aspect < 1.05) return 0.22;
|
||||
return 0.45;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user