fix(workspace): slide the content and header into place when a source opens (#1895)

* fix(workspace): slide the content and header into place when a source opens

Opening an Xtream portal from the dashboard inserted the 323 px context
panel and the header Back in the frame that swaps the page: the content
pane jumped right by the panel's width and the switcher, search and actions
by Back's slot. That one frame was J2's whole layout shift (0.2327: 0.222
for the content pane, 0.011 for the header, scored with the pane's
distance), plus 0.0001 when the category search and sort buttons appeared
with the categories.

LayoutSlideDirective animates these moves as a 180 ms FLIP: the new layout
applies at once and only a transform animates, so nothing waits and no
layout shift is recorded. The content surface covers the strip it uncovers
while sliding, Back fades in, and reduced motion keeps the jump. The
category search and sort buttons render disabled while the list loads.

Locally J2 renderer.layoutShiftScore reads 0 (was 0.233), J1 0 / 0, J3
0.001, J4 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(journeys): lower J2 layoutShiftScore to 0

The slides land on the runner: run 38147072417 (d2cb9445d) reads
open-source/renderer.layoutShiftScore 0 with no shift recorded in all 5
measured iterations (J1 0 / 0, J3 0.001, J4 0 unchanged). Ratchet the
baseline from 0.233 to 0 and record the perf:compositing evidence for the
slide in the shell contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 08:15:18 +02:00
1 parent a9f4ae38ea
commit 6fe684af51
17 files changed
+587 -20

No files matched your search

@@ -3,12 +3,15 @@
<div class="context-header__top">
<h2>{{ title() | translate }}</h2>
<div class="context-header__actions">
@if (canSearchCategories()) {
<!-- Rendered disabled while the categories load, so the
row has its final width before they arrive. -->
@if (showCategoryListActions()) {
<button
#firstHeaderAction
type="button"
mat-icon-button
class="context-header__action"
[disabled]="!canSearchCategories()"
[class.context-header__action--active]="isSearchOpen()"
(click)="toggleCategorySearch()"
[matTooltip]="'WORKSPACE.CONTEXT.SEARCH' | translate"
@@ -19,11 +22,12 @@
<mat-icon>search</mat-icon>
</button>
}
@if (canSearchCategories()) {
@if (showCategoryListActions()) {
<button
type="button"
mat-icon-button
class="context-header__action"
[disabled]="!canSearchCategories()"
[class.context-header__action--active]="
categorySortMode() !== 'server'
"
@@ -240,6 +240,35 @@ describe('WorkspaceContextPanelComponent', () => {
expect(router.navigate).not.toHaveBeenCalled();
});
it('renders the category search and sort actions disabled while the categories load', () => {
// Present from the skeleton on, so the header row keeps its width
// when the categories arrive instead of growing by two buttons.
const loaded = xtreamCategories();
fixture.componentRef.setInput('section', 'vod');
xtreamCategories.set([]);
xtreamIsLoadingCategories.set(true);
fixture.detectChanges();
const actions = () =>
Array.from(
fixture.nativeElement.querySelectorAll(
'.context-header__action'
)
) as HTMLButtonElement[];
expect(actions()).toHaveLength(3);
expect(actions().every((button) => button.disabled)).toBe(true);
expect(fixture.componentInstance.focusTarget()).toBeNull();
xtreamCategories.set(loaded);
xtreamIsLoadingCategories.set(false);
fixture.detectChanges();
expect(actions()).toHaveLength(3);
expect(actions()[0].disabled).toBe(false);
expect(actions()[1].disabled).toBe(false);
expect(fixture.componentInstance.focusTarget()).toBe(actions()[0]);
});
it('keeps local xtream loading states quiet when no import is running', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamIsImporting.set(false);
@@ -384,6 +384,13 @@ export class WorkspaceContextPanelComponent {
(this.isXtreamCategories() && this.xtreamCategories().length > 0) ||
(this.isStalkerCategories() && this.stalkerCategories().length > 0)
);
/** Search and sort show while the list loads (disabled) or has items. */
readonly showCategoryListActions = computed(
() =>
this.canSearchCategories() ||
this.isXtreamCategoryLoading() ||
(this.isStalkerCategories() && this.isStalkerCategoryLoading())
);
readonly filteredXtreamCategories = computed(() => {
this.languageTick();
@@ -472,15 +479,17 @@ export class WorkspaceContextPanelComponent {
* The control the shell sidebar hands focus to once this rail has
* unfolded (the button the user activated to bring it back is gone by
* then): the hide chevron when it is offered, else the first header
* action. `null` while neither is rendered — categories still loading,
* or a failed Stalker load — and the sidebar falls back to the rail
* itself.
* action. `null` while neither is usable — categories still loading
* (the action is rendered but disabled), or a failed Stalker load — and
* the sidebar falls back to the rail itself.
*/
focusTarget(): HTMLElement | null {
const firstAction = this.firstHeaderAction()?.nativeElement;
return (
this.hideCategoriesButton()?.nativeElement ??
this.firstHeaderAction()?.nativeElement ??
null
(firstAction && !firstAction.hasAttribute('disabled')
? firstAction
: null)
);
}
@@ -1,4 +1,10 @@
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
<header
class="workspace-header"
[class.is-settings-route]="isSettingsRoute()"
[appLayoutSlide]="leadingNavigationKey()"
[layoutSlideRecordOn]="controlsLayoutKey()"
layoutSlideTargets=".context-drawer-toggle, .playlist-switcher, .search-field, .header-actions"
>
<!-- The page's Back, or browser history while an in-app previous page
exists, at the leading edge where desktop apps and Material's top
app bar keep navigation (beside the macOS traffic lights). The
@@ -32,6 +32,19 @@
flex-shrink: 0;
margin-inline-start: -8px;
app-region: no-drag;
// Fades in while the controls after it slide aside (LayoutSlideDirective)
// instead of showing under the switcher that is still moving away.
animation: header-back-enter 180ms ease;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
@keyframes header-back-enter {
from {
opacity: 0;
}
}
// Above the phone breakpoint the context panel is always visible beside the
@@ -20,6 +20,7 @@ import {
WorkspaceBackTarget,
} from '@iptvnator/portal/shared/util';
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
import { LayoutSlideDirective } from '../../layout-slide.directive';
@Component({
selector: 'app-playlist-switcher',
@@ -67,6 +68,7 @@ describe('WorkspaceShellHeaderComponent', () => {
.overrideComponent(WorkspaceShellHeaderComponent, {
set: {
imports: [
LayoutSlideDirective,
MatIcon,
MatIconButton,
MatTooltip,
@@ -18,10 +18,12 @@ import {
} from '@iptvnator/portal/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { WorkspaceHeaderSidebarToggle } from '../../services/helpers/workspace-shell-constants';
import { LayoutSlideDirective } from '../../layout-slide.directive';
@Component({
selector: 'app-workspace-shell-header',
imports: [
LayoutSlideDirective,
MatIcon,
MatIconButton,
MatTooltip,
@@ -119,6 +121,19 @@ export class WorkspaceShellHeaderComponent {
readonly backYieldsToDrawerToggle = computed(
() => this.showDrawerToggle() && this.backPhoneSlot() === 'yield'
);
/**
* The leading navigation: when Back or the drawer toggle appears or
* leaves, the controls after it slide instead of jumping.
*/
readonly leadingNavigationKey = computed(
() =>
`${this.backTarget() !== null}|${this.showDrawerToggle()}|${this.backYieldsToDrawerToggle()}`
);
/** Everything else that moves the controls; recorded, not slid. */
readonly controlsLayoutKey = computed(
() =>
`${this.isSettingsRoute()}|${this.hasNoPlaylists()}|${this.isElectron()}|${this.parentalLockState()}|${this.headerSidebarToggle() !== null}|${this.headerShortcut() !== null}`
);
readonly backRequested = output<void>();
readonly searchChanged = output<string>();
@@ -0,0 +1,187 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
LAYOUT_SLIDE_ACTIVE_CLASS,
LAYOUT_SLIDE_DURATION_MS,
LayoutSlideDirective,
translateX,
} from './layout-slide.directive';
@Component({
imports: [LayoutSlideDirective],
template: `
<div
[appLayoutSlide]="slideKey()"
[layoutSlideRecordOn]="recordKey()"
layoutSlideTargets=".target"
>
<span class="target"></span>
</div>
`,
})
class HostComponent {
readonly slideKey = signal('panel');
readonly recordKey = signal(0);
}
interface FakeAnimation {
readonly keyframes: Keyframe[];
readonly options: KeyframeAnimationOptions;
cancelled: boolean;
finish(): void;
}
describe('LayoutSlideDirective', () => {
let fixture: ComponentFixture<HostComponent>;
let target: HTMLElement;
let left: number;
let animations: FakeAnimation[];
let reducedMotion: boolean;
const originalMatchMedia = window.matchMedia;
beforeEach(() => {
left = 60;
animations = [];
reducedMotion = false;
window.matchMedia = ((query: string) => ({
matches: reducedMotion && query.includes('reduce'),
})) as unknown as typeof window.matchMedia;
fixture = TestBed.createComponent(HostComponent);
target = fixture.nativeElement.querySelector('.target');
Object.defineProperty(target, 'offsetLeft', {
configurable: true,
get: () => left,
});
target.animate = ((
keyframes: Keyframe[],
options: KeyframeAnimationOptions
) => {
const listeners: (() => void)[] = [];
const animation = {
keyframes,
options,
cancelled: false,
addEventListener: (_type: string, listener: () => void) =>
listeners.push(listener),
finish: () => listeners.forEach((listener) => listener()),
cancel: () => {
animation.cancelled = true;
animation.finish();
},
};
animations.push(animation);
return animation;
}) as unknown as HTMLElement['animate'];
render();
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
function render(): void {
fixture.detectChanges();
TestBed.tick();
}
it('does not animate the first render', () => {
expect(animations).toEqual([]);
});
it('slides a target from its previous position when the key changes', () => {
left = 383;
fixture.componentInstance.slideKey.set('single-pane');
render();
expect(animations).toHaveLength(1);
expect(animations[0].keyframes).toEqual([
{ transform: 'translateX(-323px)' },
{ transform: 'none' },
]);
expect(animations[0].options.duration).toBe(LAYOUT_SLIDE_DURATION_MS);
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
expect(target.style.getPropertyValue('--layout-slide-start')).toBe(
'0px'
);
expect(target.style.getPropertyValue('--layout-slide-end')).toBe(
'323px'
);
animations[0].finish();
expect(target.classList).not.toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
expect(target.style.getPropertyValue('--layout-slide-end')).toBe('');
});
it('records moves of other changes instead of sliding them', () => {
left = 120;
fixture.componentInstance.recordKey.set(1);
render();
expect(animations).toEqual([]);
left = 80;
fixture.componentInstance.slideKey.set('single-pane');
render();
expect(animations[0].keyframes[0]).toEqual({
transform: 'translateX(40px)',
});
});
it('keeps a finished slide from clearing a newer one', () => {
left = 383;
fixture.componentInstance.slideKey.set('single-pane');
render();
left = 60;
fixture.componentInstance.slideKey.set('panel');
render();
animations[0].finish();
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
expect(target.style.getPropertyValue('--layout-slide-start')).toBe(
'323px'
);
});
it('starts an interrupted slide from where the target is drawn', () => {
left = 383;
fixture.componentInstance.slideKey.set('single-pane');
render();
// 40 ms in, the first slide draws the target at 172 px.
target.style.transform = 'matrix(1, 0, 0, 1, -211, 0)';
left = 60;
fixture.componentInstance.slideKey.set('panel');
render();
expect(animations).toHaveLength(2);
expect(animations[0].cancelled).toBe(true);
expect(animations[1].keyframes[0]).toEqual({
transform: 'translateX(112px)',
});
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
});
it('reads the x translation of computed transforms', () => {
expect(translateX('none')).toBe(0);
expect(translateX('matrix(1, 0, 0, 1, -211.5, 0)')).toBe(-211.5);
expect(
translateX(
'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 44, 0, 0, 1)'
)
).toBe(44);
});
it('does not animate when the target did not move', () => {
fixture.componentInstance.slideKey.set('single-pane');
render();
expect(animations).toEqual([]);
});
it('jumps under prefers-reduced-motion', () => {
reducedMotion = true;
left = 383;
fixture.componentInstance.slideKey.set('single-pane');
render();
expect(animations).toEqual([]);
expect(target.classList).not.toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
});
});
@@ -0,0 +1,178 @@
import {
afterRenderEffect,
DestroyRef,
Directive,
ElementRef,
inject,
input,
} from '@angular/core';
/** Matches the context panel's own width transition. */
export const LAYOUT_SLIDE_DURATION_MS = 180;
export const LAYOUT_SLIDE_ACTIVE_CLASS = 'layout-slide-active';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
/**
* Slides shell chrome from where it was to where a layout change put it
* (FLIP) instead of letting it jump in one frame. The new layout applies at
* once: each target only starts translated back to its previous position
* and animates its transform to none, so nothing waits for the animation,
* and Chromium records no layout shift for a transform.
*
* A change of `appLayoutSlide` slides the targets (`layoutSlideTargets`,
* selectors inside the host; the host itself when empty). A change of
* `layoutSlideRecordOn`, and a resize of the host or a target, only records
* where the targets are, so the next slide starts from where they are
* really drawn. Positions are `offsetLeft` values, which ignore transforms.
* Under `prefers-reduced-motion` the change stays an instant jump.
* Contract: docs/architecture/workspace-shell.md ("Layout slides").
*/
@Directive({
selector: '[appLayoutSlide]',
})
export class LayoutSlideDirective {
readonly appLayoutSlide = input.required<unknown>();
readonly layoutSlideRecordOn = input<unknown>(null);
readonly layoutSlideTargets = input('');
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly positions = new WeakMap<HTMLElement, number>();
private readonly running = new WeakMap<HTMLElement, Animation>();
constructor() {
let resizeObserver: ResizeObserver | null = null;
let observed: readonly HTMLElement[] = [];
let previousKey: unknown;
let initialized = false;
afterRenderEffect({
read: () => {
const key = this.appLayoutSlide();
this.layoutSlideRecordOn();
const targets = this.targets();
if (initialized && key !== previousKey) {
this.slide(targets);
} else {
this.record(targets);
}
previousKey = key;
initialized = true;
if (typeof ResizeObserver === 'undefined') {
return;
}
resizeObserver ??= new ResizeObserver(() =>
this.record(this.targets())
);
const next = [this.host.nativeElement, ...targets];
if (!sameElements(observed, next)) {
resizeObserver.disconnect();
next.forEach((element) => resizeObserver?.observe(element));
observed = next;
}
},
});
inject(DestroyRef).onDestroy(() => resizeObserver?.disconnect());
}
private targets(): HTMLElement[] {
const host = this.host.nativeElement;
const selector = this.layoutSlideTargets();
return selector
? Array.from(host.querySelectorAll<HTMLElement>(selector))
: [host];
}
private record(targets: readonly HTMLElement[]): void {
for (const target of targets) {
this.positions.set(target, target.offsetLeft);
}
}
private slide(targets: readonly HTMLElement[]): void {
// Every position is read before the first animation starts, so the
// layout is computed once.
const moves: [HTMLElement, number][] = [];
for (const target of targets) {
const previous = this.positions.get(target);
const next = target.offsetLeft;
this.positions.set(target, next);
// A slide still running is interrupted: the new one starts where
// the target is drawn now, not where the old one was heading.
const drawn = this.running.has(target)
? translateX(getComputedStyle(target).transform)
: 0;
if (previous !== undefined && previous + drawn !== next) {
moves.push([target, previous + drawn - next]);
}
}
if (prefersReducedMotion()) {
return;
}
for (const [target, offset] of moves) {
if (typeof target.animate === 'function') {
this.animate(target, offset);
}
}
}
/**
* While it runs the target carries `layout-slide-active`, and the width
* it uncovers on either side as `--layout-slide-start` /
* `--layout-slide-end`, so its stylesheet can fill that strip.
*/
private animate(target: HTMLElement, offset: number): void {
const superseded = this.running.get(target);
const animation = target.animate(
[{ transform: `translateX(${offset}px)` }, { transform: 'none' }],
{ duration: LAYOUT_SLIDE_DURATION_MS, easing: 'ease' }
);
this.running.set(target, animation);
superseded?.cancel();
target.classList.add(LAYOUT_SLIDE_ACTIVE_CLASS);
target.style.setProperty(
'--layout-slide-start',
`${Math.max(offset, 0)}px`
);
target.style.setProperty(
'--layout-slide-end',
`${Math.max(-offset, 0)}px`
);
const settle = () => {
if (this.running.get(target) !== animation) {
return;
}
this.running.delete(target);
target.classList.remove(LAYOUT_SLIDE_ACTIVE_CLASS);
target.style.removeProperty('--layout-slide-start');
target.style.removeProperty('--layout-slide-end');
};
animation.addEventListener('finish', settle);
animation.addEventListener('cancel', settle);
}
}
function sameElements(
a: readonly HTMLElement[],
b: readonly HTMLElement[]
): boolean {
return a.length === b.length && a.every((element, i) => element === b[i]);
}
/** The x translation of a computed `transform` value (0 for `none`). */
export function translateX(transform: string): number {
const values = /^matrix(3d)?\((.+)\)$/.exec(transform.trim());
if (!values) {
return 0;
}
const parts = values[2].split(',').map(Number);
const x = values[1] ? parts[12] : parts[4];
return Number.isFinite(x) ? x : 0;
}
function prefersReducedMotion(): boolean {
return (
typeof matchMedia === 'function' &&
matchMedia(REDUCED_MOTION_QUERY).matches
);
}
@@ -71,9 +71,13 @@
(accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)"
/>
<!-- The content frame slides to make room for the context panel
instead of jumping (see LayoutSlideDirective). -->
<div
class="workspace-body"
[class.single-pane]="!facade.showContextPanel()"
[appLayoutSlide]="facade.showContextPanel()"
layoutSlideTargets=".workspace-content-frame"
>
@if (facade.showContextPanel()) {
<!-- Phone-only (CSS-hidden above 640px): tapping the scrim is
@@ -114,6 +114,20 @@ app-workspace-shell-context-sidebar {
clip-path: inset(0);
pointer-events: none;
}
// While the frame slides into place beside a context panel that appeared
// or left (LayoutSlideDirective), its old width is wider than the new
// one, so the side it moves away from would show the body colour. The
// content surface extends over that strip until the slide ends.
&.layout-slide-active::after {
content: '';
position: absolute;
inset: 0 calc(-1 * var(--layout-slide-end, 0px)) 0
calc(-1 * var(--layout-slide-start, 0px));
z-index: -1;
background: var(--app-content-bg, var(--mat-sys-surface));
pointer-events: none;
}
}
.workspace-content {
@@ -18,6 +18,7 @@ import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
} from '@iptvnator/workspace/shell/util';
import { LayoutSlideDirective } from './layout-slide.directive';
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
import { WorkspaceShellComponent } from './workspace-shell.component';
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
@@ -265,6 +266,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -361,6 +363,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -439,6 +442,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -513,6 +517,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -613,6 +618,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
LayoutSlideDirective,
],
providers: [
{
@@ -21,6 +21,7 @@ import { WorkspaceShellContextSidebarComponent } from './components/workspace-sh
import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component';
import { WorkspaceShellImportOverlayComponent } from './components/workspace-shell-import-overlay/workspace-shell-import-overlay.component';
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
import { LayoutSlideDirective } from './layout-slide.directive';
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
@@ -39,6 +40,7 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
imports: [
CdkTrapFocus,
ExternalPlaybackDockComponent,
LayoutSlideDirective,
TranslatePipe,
PlaylistDropOverlayComponent,
PlaylistDropZoneDirective,