fix(detail): let the pane width choose between the Back lane and the bar

Review follow-up. The bar fallback keyed on the 640px viewport breakpoint,
but beside the context panel a desktop pane can be narrower than a phone:
at a 641px window the lane left the player card ~150px wide. The shell now
measures its own border box with a ResizeObserver and sets
shell-host--compact below 400px, so the bar covers phones and squeezed
desktop panes alike, independent of scrollbar width.

The Electron E2E now asserts the lane mode at 1280/780px, sweeps the bar
at 700/375px (opaque, hit-tests over whatever scrolls beneath it), and
checks the season heading stays on one line from 680 to 1600px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-30 20:31:03 +02:00
1 parent 377b22124b
commit 38b9e4e267
5 files changed
+221 -15

No files matched your search

@@ -18,9 +18,15 @@ import {
// workspace rail and category panel leave the detail pane far narrower than
// either, and the heading used to wrap beside its actions (two lines at
// 1280px, three at 780px) with its first word under the arrow.
//
// A pane under 400px cannot spare the lane: a 700px window beside the
// category panel, or a phone. There the control sits in an opaque sticky bar,
// and whatever reaches the arrow's band must be hidden beneath the bar, never
// visible beside the arrow.
// ---------------------------------------------------------------------------
const widths = [1280, 780];
const compactWidths = [700, 375];
const playerCorner = 56;
type Sweep = {
@@ -79,6 +85,71 @@ function sweepDetail(shell: Locator): Promise<Sweep> {
}, playerCorner);
}
type BarSweep = {
background: string;
/** Scroll positions where a target reached the bar's band. */
beneath: number;
/** Points of a target in the bar's band that hit content, not the bar. */
exposed: string[];
};
/**
* Compact counterpart of {@link sweepDetail}: the bar sits in flow and
* content scrolls beneath it, so boxes do intersect. Instead, every sampled
* point of the heading or the player's corner that is inside the bar's band
* must hit-test to the bar: hidden beneath it, and unreachable by a tap.
*/
function sweepBar(shell: Locator): Promise<BarSweep> {
return shell.evaluate((element, corner) => {
const bar = element.querySelector('.shell__navigation');
if (!bar) throw new Error('The detail shows no Back control.');
const sweep = {
background: getComputedStyle(bar).backgroundColor,
beneath: 0,
exposed: [] as string[],
};
const max = element.scrollHeight - element.clientHeight;
for (let top = 0; ; top = Math.min(max, top + 24)) {
element.scrollTop = top;
const band = bar.getBoundingClientRect();
const title = element
.querySelector('.section-title')
?.getBoundingClientRect();
const player = element
.querySelector('app-portal-inline-player')
?.getBoundingClientRect();
const targets = {
title,
player: player && {
left: player.left,
top: player.top,
right: player.left + corner,
bottom: player.top + corner,
},
};
for (const [name, box] of Object.entries(targets)) {
if (!box) continue;
const from = Math.max(box.top, band.top);
const to = Math.min(box.bottom, band.bottom);
if (to <= from) continue;
sweep.beneath += 1;
const y = (from + to) / 2;
for (const x of [box.left + 2, box.right - 2]) {
const hit = element.ownerDocument.elementFromPoint(x, y);
if (!hit || !bar.contains(hit)) {
sweep.exposed.push(
`${name} at scrollTop ${top} hits ${hit?.tagName}.${String(hit?.className)}`
);
}
}
}
if (top >= max) break;
}
element.scrollTop = 0;
return sweep;
}, playerCorner);
}
/** Line boxes of the heading's text; 1 means it did not wrap. */
function headingLineCount(page: Page): Promise<number> {
return page.locator('.section-title').evaluate((heading) => {
@@ -92,15 +163,17 @@ function headingLineCount(page: Page): Promise<number> {
}
/**
* Lets the browse↔watch morph and the player's fade-in settle. Bounded, so a
* paused animation elsewhere in the player cannot stall the test.
* Lets the browse↔watch morph, the player's fade-in and the workspace's own
* transitions settle — crossing into the phone layout slides the category
* drawer out over the page for 200ms. Bounded, so a paused animation
* elsewhere cannot stall the test.
*/
async function settle(shell: Locator): Promise<void> {
await shell.evaluate((element) =>
Promise.race([
Promise.all(
element
.getAnimations({ subtree: true })
element.ownerDocument
.getAnimations()
.filter(
(animation) =>
animation.effect?.getTiming().iterations !==
@@ -122,6 +195,11 @@ async function expectBackClearOfContent(
const shell = page.locator('app-portal-detail-shell');
for (const width of widths) {
await page.setViewportSize({ width, height: 800 });
// Fail on the mode first: a lane assertion against the bar would
// only report confusing intersections.
await expect(shell, `${state} at ${width}px`).not.toHaveClass(
/shell-host--compact/
);
await settle(shell);
const sweep = await sweepDetail(shell);
@@ -137,6 +215,35 @@ async function expectBackClearOfContent(
}
expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1);
}
for (const width of compactWidths) {
await page.setViewportSize({ width, height: 800 });
await expect(shell, `${state} at ${width}px`).toHaveClass(
/shell-host--compact/
);
await settle(shell);
const sweep = await sweepBar(shell);
expect(sweep.exposed, `${state} at ${width}px`).toEqual([]);
expect(sweep.beneath, `${state} at ${width}px`).toBeGreaterThan(0);
// Opaque, or what scrolls beneath would show through.
expect(sweep.background, `${state} at ${width}px`).toMatch(/^rgb\(/);
expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1);
}
}
/**
* The actions move onto their own row before the heading wraps, at every
* pane width that can hold the heading at all — including the widths where
* the lane gives way to the bar.
*/
async function expectHeadingOnOneLine(page: Page): Promise<void> {
const wrapped: number[] = [];
for (let width = 680; width <= 1600; width += 20) {
await page.setViewportSize({ width, height: 800 });
if ((await headingLineCount(page)) !== 1) wrapped.push(width);
}
expect(wrapped).toEqual([]);
}
test.describe('Portal detail Back lane', () => {
@@ -167,6 +274,7 @@ test.describe('Portal detail Back lane', () => {
await expect(episodes.first()).toBeVisible({ timeout: 20_000 });
await expectBackClearOfContent(page, 'browse');
await expectHeadingOnOneLine(page);
await page.setViewportSize({ width: widths[0], height: 800 });
await episodes.first().click();