diff --git a/src/app/root/root.component.scss b/src/app/root/root.component.scss
index 9eb198417ad..6e617a3e48a 100644
--- a/src/app/root/root.component.scss
+++ b/src/app/root/root.component.scss
@@ -14,3 +14,31 @@
top: 0;
}
}
+
+// Admin-sidebar left gutter. Driven by the `ds-admin-sidebar-*` class set in root.component.html (from
+// sidebarPaddingState$) rather than the @slideSidebarPadding Angular animation. The animation needed a
+// concrete width from the browser-only CSS-variable store, so on the server it rendered padding-left:0
+// and the authenticated page jumped right when the anti-flicker SSR snapshot was removed. Resolving the
+// gutter from the `--ds-admin-sidebar-*` custom properties in CSS instead renders identically on the
+// server (snapshot) and the browser (live app) - no hardcoded px, theme- and viewport-aware - and the
+// transition keeps the pin/unpin slide. 'hidden' (no admin sidebar) keeps the default padding-left: 0.
+.outer-wrapper {
+ // padding-left:0 (no admin sidebar); explicit for self-documentation.
+ &.ds-admin-sidebar-hidden {
+ padding-left: 0;
+ }
+
+ &.ds-admin-sidebar-unpinned {
+ padding-left: var(--ds-admin-sidebar-fixed-element-width);
+ }
+
+ &.ds-admin-sidebar-pinned {
+ padding-left: var(--ds-admin-sidebar-total-width);
+ }
+
+ // Slide only genuine pin/unpin toggles. The class is added after first paint (gutterTransitionEnabled)
+ // so the initial SSR->CSR gutter resolution behind the anti-flicker overlay never animates.
+ &.ds-admin-sidebar-animate {
+ transition: padding-left 300ms ease-in-out;
+ }
+}
diff --git a/src/app/root/root.component.spec.ts b/src/app/root/root.component.spec.ts
index 77b39d29088..e83811880c5 100644
--- a/src/app/root/root.component.spec.ts
+++ b/src/app/root/root.component.spec.ts
@@ -7,6 +7,7 @@ import {
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
+import { of } from 'rxjs';
import { AccessibilitySettingsService } from '../accessibility/accessibility-settings.service';
import { AccessibilitySettingsServiceStub } from '../accessibility/accessibility-settings.service.stub';
@@ -72,4 +73,50 @@ describe('RootComponent', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('should emit the hidden gutter state when the admin sidebar is not visible', () => {
+ spyOn(TestBed.inject(MenuService), 'isMenuVisibleWithVisibleSections').and.returnValue(of(false));
+ component.ngOnInit();
+ component.browserOsClasses.next(['browser-firefox', 'browser-firefox-windows']);
+ fixture.detectChanges();
+
+ let state: string;
+ component.sidebarPaddingState$.subscribe((value: string) => state = value);
+ expect(state).toEqual('hidden');
+
+ // the gutter class must be merged with the browser/OS classes, not replace them
+ let classes: string[];
+ component.outerWrapperClasses$.subscribe((value: string[]) => classes = value);
+ expect(classes).toEqual(['browser-firefox', 'browser-firefox-windows', 'ds-admin-sidebar-hidden']);
+
+ const wrapper: HTMLElement = fixture.nativeElement.querySelector('.outer-wrapper');
+ expect(Array.from(wrapper.classList)).toContain('browser-firefox');
+ expect(Array.from(wrapper.classList)).toContain('browser-firefox-windows');
+ expect(Array.from(wrapper.classList)).toContain('ds-admin-sidebar-hidden');
+ });
+
+ it('should enable the gutter transition only after the first paint', () => {
+ const paints: FrameRequestCallback[] = [];
+ spyOn(window, 'requestAnimationFrame').and.callFake((callback: FrameRequestCallback) => {
+ paints.push(callback);
+ return 0;
+ });
+
+ const gutterFixture = TestBed.createComponent(RootComponent);
+ gutterFixture.detectChanges();
+ const wrapper: HTMLElement = gutterFixture.nativeElement.querySelector('.outer-wrapper');
+
+ // the gutter itself is rendered right away (this is what removes the SSR -> CSR jump) ...
+ expect(Array.from(wrapper.classList)).toContain('ds-admin-sidebar-pinned');
+ // ... but it must not animate before the first paint
+ expect(gutterFixture.componentInstance.gutterTransitionEnabled).toBeFalse();
+ expect(Array.from(wrapper.classList)).not.toContain('ds-admin-sidebar-animate');
+
+ expect(paints.length).toBeGreaterThan(0);
+ paints.forEach((callback: FrameRequestCallback) => callback(0));
+ gutterFixture.detectChanges();
+
+ expect(gutterFixture.componentInstance.gutterTransitionEnabled).toBeTrue();
+ expect(Array.from(wrapper.classList)).toContain('ds-admin-sidebar-animate');
+ });
});
diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts
index 8ce800f5f5f..f6430de99c0 100644
--- a/src/app/root/root.component.ts
+++ b/src/app/root/root.component.ts
@@ -3,6 +3,7 @@ import {
NgClass,
} from '@angular/common';
import {
+ AfterViewInit,
Component,
Inject,
Input,
@@ -38,7 +39,6 @@ import {
} from '../core/services/window.service';
import { ThemedFooterComponent } from '../footer/themed-footer.component';
import { ThemedHeaderNavbarWrapperComponent } from '../header-nav-wrapper/themed-header-navbar-wrapper.component';
-import { slideSidebarPadding } from '../shared/animations/slide';
import { HostWindowService } from '../shared/host-window.service';
import { LiveRegionComponent } from '../shared/live-region/live-region.component';
import { ThemedLoadingComponent } from '../shared/loading/themed-loading.component';
@@ -52,7 +52,6 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne
selector: 'ds-base-root',
templateUrl: './root.component.html',
styleUrls: ['./root.component.scss'],
- animations: [slideSidebarPadding],
imports: [
AsyncPipe,
LiveRegionComponent,
@@ -68,12 +67,37 @@ import { SystemWideAlertBannerComponent } from '../system-wide-alert/alert-banne
TranslateModule,
],
})
-export class RootComponent implements OnInit {
+export class RootComponent implements OnInit, AfterViewInit {
theme: Observable = of({} as any);
isSidebarVisible$: Observable;
slideSidebarOver$: Observable;
collapsedSidebarWidth$: Observable;
expandedSidebarWidth$: Observable;
+
+ /**
+ * The admin-sidebar padding state ('hidden' | 'unpinned' | 'pinned') used to drive the
+ * outer-wrapper's left gutter via CSS classes (see root.component.scss) instead of an Angular
+ * animation. CSS resolves the gutter width from the `--ds-admin-sidebar-*` custom properties, so it
+ * is rendered identically on the server (the anti-flicker SSR snapshot) and the browser (the live
+ * app) - no browser-only CSS-variable read, no hardcoded px, and it stays theme- and viewport-aware.
+ */
+ sidebarPaddingState$: Observable;
+
+ /**
+ * The classes on the outer wrapper: the browser/OS classes this branch already carries, plus the
+ * admin-sidebar gutter class. Merged into a single stream because Angular allows one [ngClass]
+ * binding per element and the wrapper already has one.
+ */
+ outerWrapperClasses$: Observable;
+
+ /**
+ * Enables the gutter's `transition: padding-left` only AFTER the first browser paint. The initial
+ * SSR->CSR gutter resolution happens behind the anti-flicker overlay; without this gate a plain CSS
+ * transition would animate that initial 0->gutter change (the overlay settle detector only watches
+ * DOM mutations, not style changes), which could leak a 300ms slide right as the overlay is removed.
+ * Off on the server and on first render, so only genuine pin/unpin toggles animate.
+ */
+ gutterTransitionEnabled = false;
notificationOptions: INotificationBoardOptions;
models: any;
@@ -113,6 +137,8 @@ export class RootComponent implements OnInit {
this.isSidebarVisible$ = this.menuService.isMenuVisibleWithVisibleSections(MenuID.ADMIN);
+ // Still provided to ; the sidebar element itself sizes from CSS vars, so a
+ // null value on the server (the store is browser-only) is harmless there.
this.expandedSidebarWidth$ = this.cssService.getVariable('--ds-admin-sidebar-total-width').pipe(
skipWhile((val) => !val),
first(),
@@ -129,11 +155,36 @@ export class RootComponent implements OnInit {
startWith(true),
);
+ // Drive the outer-wrapper gutter via a CSS class instead of the @slideSidebarPadding animation: the
+ // animation needs a concrete width from the browser-only CSS-variable store, so on the server it
+ // rendered padding-left:0 and the authenticated page jumped right when the SSR snapshot was removed.
+ // The CSS class resolves the gutter from `--ds-admin-sidebar-*` (see root.component.scss), identically
+ // on server and browser - fixing the jump without any hardcoded width.
+ this.sidebarPaddingState$ = combineLatestObservable([this.isSidebarVisible$, this.slideSidebarOver$]).pipe(
+ map(([visible, over]) => !visible ? 'hidden' : over ? 'unpinned' : 'pinned'),
+ );
+ this.outerWrapperClasses$ = combineLatestObservable([
+ this.browserOsClasses.asObservable(),
+ this.sidebarPaddingState$,
+ ]).pipe(
+ map(([osClasses, state]) => [...osClasses, `ds-admin-sidebar-${state}`]),
+ );
+
if (this.router.url === getPageInternalServerErrorRoute()) {
this.shouldShowRouteLoader = false;
}
}
+ ngAfterViewInit(): void {
+ // Enable the gutter slide only after the first paint (browser only; requestAnimationFrame is not
+ // defined under SSR), so the initial padding resolution never animates - see gutterTransitionEnabled.
+ if (typeof requestAnimationFrame === 'function') {
+ requestAnimationFrame(() => {
+ this.gutterTransitionEnabled = true;
+ });
+ }
+ }
+
skipToMainContent() {
const mainContent = document.getElementById('main-content');
if (mainContent) {
diff --git a/src/themes/custom/app/root/root.component.ts b/src/themes/custom/app/root/root.component.ts
index 2bdab293bf6..e3611b32a77 100644
--- a/src/themes/custom/app/root/root.component.ts
+++ b/src/themes/custom/app/root/root.component.ts
@@ -11,7 +11,6 @@ import { ThemedBreadcrumbsComponent } from '../../../../app/breadcrumbs/themed-b
import { ThemedFooterComponent } from '../../../../app/footer/themed-footer.component';
import { ThemedHeaderNavbarWrapperComponent } from '../../../../app/header-nav-wrapper/themed-header-navbar-wrapper.component';
import { RootComponent as BaseComponent } from '../../../../app/root/root.component';
-import { slideSidebarPadding } from '../../../../app/shared/animations/slide';
import { LiveRegionComponent } from '../../../../app/shared/live-region/live-region.component';
import { ThemedLoadingComponent } from '../../../../app/shared/loading/themed-loading.component';
import { NotificationsBoardComponent } from '../../../../app/shared/notifications/notifications-board/notifications-board.component';
@@ -23,7 +22,6 @@ import { SystemWideAlertBannerComponent } from '../../../../app/system-wide-aler
styleUrls: ['../../../../app/root/root.component.scss'],
// templateUrl: './root.component.html',
templateUrl: '../../../../app/root/root.component.html',
- animations: [slideSidebarPadding],
imports: [
AsyncPipe,
LiveRegionComponent,