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

@@ -189,13 +189,12 @@
}
}
// A phone cannot spare a 72px column: the player's controls would clip and
// the hero actions would wrap. There the control takes a row instead — an
// opaque sticky bar in flow that content scrolls beneath, never alongside.
@media (max-width: 640px) {
:host(.shell-host--back) {
--detail-back-lane: 0px;
}
// A pane too narrow to spare a 72px column (a phone, or a desktop pane beside
// the context panel; the component sets the class from the pane's width)
// would clip the player's controls. There the control takes a row instead —
// an opaque sticky bar in flow that content scrolls beneath, never alongside.
:host(.shell-host--compact) {
--detail-back-lane: 0px;
.shell__navigation {
top: 0;
@@ -451,3 +451,67 @@ describe('PortalDetailShellComponent', () => {
expect(host.closeRequests).toBe(0);
});
});
describe('PortalDetailShellComponent pane width', () => {
const original = globalThis.ResizeObserver;
const disconnect = jest.fn();
// The hero observes itself too, so keep each callback with its target.
let observers: { callback: ResizeObserverCallback; target?: Element }[];
beforeEach(() => {
observers = [];
disconnect.mockClear();
globalThis.ResizeObserver = class {
private readonly entry: (typeof observers)[number];
constructor(callback: ResizeObserverCallback) {
this.entry = { callback };
observers.push(this.entry);
}
observe(target: Element): void {
this.entry.target = target;
}
unobserve = jest.fn();
disconnect = disconnect;
} as unknown as typeof ResizeObserver;
TestBed.configureTestingModule({
imports: [HostComponent, TranslateModule.forRoot()],
});
});
afterEach(() => {
globalThis.ResizeObserver = original;
});
const resize = (shell: HTMLElement, inlineSize: number): void => {
const observer = observers.find(({ target }) => target === shell);
if (!observer) throw new Error('The shell does not observe itself.');
observer.callback(
[
{
borderBoxSize: [{ inlineSize, blockSize: 800 }],
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver
);
};
it('trades the Back lane for a bar when the pane itself is narrow', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const shell = (fixture.nativeElement as HTMLElement).querySelector(
'app-portal-detail-shell'
) as HTMLElement;
// A 780px window leaves the pane ~402px beside the context panel.
resize(shell, 402);
expect(shell.classList).not.toContain('shell-host--compact');
// A 641px window leaves it ~260px: too narrow for the lane.
resize(shell, 260);
expect(shell.classList).toContain('shell-host--compact');
resize(shell, 1280);
expect(shell.classList).not.toContain('shell-host--compact');
fixture.destroy();
expect(disconnect).toHaveBeenCalled();
});
});
@@ -2,6 +2,7 @@ import { NgTemplateOutlet } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
ElementRef,
Injector,
computed,
@@ -23,6 +24,14 @@ import {
DetailTagsTemplateDirective,
} from './detail-template.directives';
/**
* Below this pane width the Back lane (16 + 40 + 16px) would leave the player
* card under ~316px, where its control row clips, so the control takes a
* sticky bar instead. The pane decides, not the viewport: beside the context
* panel a desktop pane can be narrower than a phone.
*/
const COMPACT_SHELL_WIDTH = 400;
/**
* Two-state layout shell for portal VOD/series detail pages.
*
@@ -69,6 +78,7 @@ import {
export class PortalDetailShellComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
private readonly backButton =
viewChild<ElementRef<HTMLButtonElement>>('backButton');
@@ -110,6 +120,7 @@ export class PortalDetailShellComponent {
element.focus({ preventScroll: true });
}
});
this.observeCompactWidth();
let wasWatch = false;
effect(() => {
const watch = this.isWatch();
@@ -243,4 +254,24 @@ export class PortalDetailShellComponent {
}
element.scrollTo({ top: 0, behavior: 'auto' });
}
/**
* Toggles `shell-host--compact` straight on the host, so the class lands
* in the same frame as the resize instead of after a change-detection
* pass. The border box keeps the threshold independent of scrollbar width.
*/
private observeCompactWidth(): void {
if (typeof ResizeObserver === 'undefined') return;
const element = this.host.nativeElement;
const observer = new ResizeObserver(([entry]) => {
const width =
entry?.borderBoxSize?.[0]?.inlineSize ?? element.offsetWidth;
element.classList.toggle(
'shell-host--compact',
width < COMPACT_SHELL_WIDTH
);
});
observer.observe(element);
this.destroyRef.onDestroy(() => observer.disconnect());
}
}