feat(portal-shell): align header logo zone with the sidebar rail
CI / commits (pull_request) Successful in 1m54s
CI / scan (pull_request) Successful in 2m3s
CI / check (pull_request) Successful in 2m10s
CI / a11y (pull_request) Successful in 2m31s
CI / perf (pull_request) Successful in 4m43s

Promote the sidebar's collapsed state to a shell-level
`LayoutStateService` (signal + localStorage persistence) so the header
can read it. Split the header into two zones: a logo zone whose width
matches the sidebar (16rem expanded / 4rem collapsed) and the
existing search + actions cluster on the right. The logo glyph stays
visible at both widths; the "APF Portal" wordmark hides when the
rail is collapsed.

The logo-zone right border and the sidebar right border share the
same x coordinate, so they read as one continuous column separator
running the full height of the shell. The width transition matches
the sidebar's (0.18s ease-out) and is skipped under
`prefers-reduced-motion: reduce`.

Side-effect: cleans up `<app-sidebar>` — its own signal + effect +
storage glue is gone; it now just delegates reads and the toggle
click to the service. Same UX, single source of truth, ready to
absorb future shared shell state (density, theme, ...) without
prop-drilling.
This commit is contained in:
Julien Gautier
2026-05-11 01:11:05 +02:00
parent 29f79d677e
commit 5795e3c19a
7 changed files with 243 additions and 92 deletions
@@ -0,0 +1,53 @@
import { Injectable, effect, signal } from '@angular/core';
/**
* Shell-level layout state shared by the header and the sidebar.
*
* Currently a single piece of state: whether the sidebar is collapsed
* to its icon-only rail. The collapsed state is persisted in
* `localStorage` so it survives reloads without a backend round-trip.
*
* Lives outside of `<app-sidebar>` so that `<app-header>` can also
* read it — that's what lets the header's logo zone size itself to
* match the sidebar width, and stay perfectly aligned with the rail
* below it. As more cross-cutting shell state lands (density, theme,
* panel pinning, …), it joins here rather than spreading across
* sibling components.
*/
const STORAGE_KEY = 'portal-shell:sidebar-collapsed';
@Injectable({ providedIn: 'root' })
export class LayoutStateService {
private readonly _sidebarCollapsed = signal(readPersistedCollapsed());
readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly();
constructor() {
effect(() => {
const value = this._sidebarCollapsed();
try {
localStorage.setItem(STORAGE_KEY, value ? '1' : '0');
} catch {
// localStorage can throw in private-mode contexts; the UI
// still works, the preference just doesn't persist.
}
});
}
toggleSidebar(): void {
this._sidebarCollapsed.update((v) => !v);
}
setSidebarCollapsed(value: boolean): void {
this._sidebarCollapsed.set(value);
}
}
function readPersistedCollapsed(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
}