diff --git a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts b/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts index 3781c2b27..0c0ceed2b 100644 --- a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts +++ b/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts @@ -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 { }, 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 { + 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 { return page.locator('.section-title').evaluate((heading) => { @@ -92,15 +163,17 @@ function headingLineCount(page: Page): Promise { } /** - * 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 { 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 { + 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(); diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index d2b27ccd6..8cc2da1d7 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -37,10 +37,14 @@ position puts the arrow over a heading or the player. The shell derives the lane from the button's geometry and publishes it as `--detail-back-lane`, which `content-hero` and `content-about` read too; hosts with `backAvailable=false` keep their own inset. The lane is horizontal because watch state is short of -height, not width. The phone breakpoint (640 px) is the exception: a 72 px column -would clip the player's controls, so there the wrapper becomes an in-flow 56 px -sticky bar on `--app-header-bg` that content scrolls beneath. Electron E2E -(`detail-back-lane.e2e.ts`) sweeps both states at 1280 and 780 px. +height, not width. A pane narrower than 400 px is the exception — a phone, or a +desktop pane beside the context panel — because a 72 px column there would clip +the player's controls. The pane decides, not the viewport: a `ResizeObserver` +on the shell's border box (scrollbar-independent) sets `shell-host--compact`, +and the wrapper becomes an in-flow 56 px sticky bar on `--app-header-bg` that +content scrolls beneath and that takes the taps over it. Electron E2E +(`detail-back-lane.e2e.ts`) sweeps both states with the lane at 1280 and +780 px and with the bar at 700 and 375 px. The sticky control is route-level Back in both states: it emits `backClicked` whether or not inline playback is active, so the arrow keeps one meaning and diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss index 9dfee6305..c60c3ada5 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss @@ -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; diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts index 0173f234a..55e51eeda 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.spec.ts @@ -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(); + }); +}); diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index c95fad825..eac7d90cf 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -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); private readonly injector = inject(Injector); + private readonly destroyRef = inject(DestroyRef); private readonly backButton = viewChild>('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()); + } }