From b6aa17f6a09e966a64ea1de0820e6e71e416fbdf Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Mon, 11 May 2026 01:14:42 +0200 Subject: [PATCH] feat(portal-shell): align header logo zone with the sidebar rail (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Split the header into two zones: a **logo zone** whose width tracks the sidebar (16 rem expanded / 4 rem collapsed), and the existing search + actions cluster on the right. - The logo glyph stays at both widths; the "APF Portal" wordmark hides when the rail collapses, mirroring the sidebar's icon-only mode. - Promote the sidebar's `collapsed` state to a new [`LayoutStateService`](apps/portal-shell/src/app/state/layout-state.service.ts) (Signals + `localStorage`) so header and sidebar share a single source of truth. ## Why a service rather than prop-drilling Two reasons: 1. The state is **shell-level**, not sidebar-internal — the header now reads it, and future surfaces (right rail, breadcrumbs, command palette) will too. Passing it down via inputs would force `` to act as the owner and turn every intermediate component into a relay. 2. As we add other cross-cutting shell state (density, theme, panel pinning, RTL), they all belong in the same place. `LayoutStateService` is the natural collector and stays trivial as long as we don't over-broaden it. v1 ships with one signal — keeping it narrow. The service is `providedIn: 'root'` (singleton), Signals-only, and owns localStorage persistence — same UX as before, just relocated. ## Decisions worth flagging - **Widths stay duplicated between `header.scss` and `sidebar.scss`** (16 rem / 4 rem). Extracting a shared SCSS variable would be premature — only two callers, and the coupling is loud (cross-file comment in `header.scss`). If a third surface needs the same widths, we promote to a shared token. - **Border continuity.** The logo zone's right border and the sidebar's right border share the same x coordinate, so they read as one continuous vertical separator running the full shell height. Same `#e5e7eb` so the seam is invisible. - **Width transition** matches the sidebar's (`0.18s ease-out`) and is skipped under `prefers-reduced-motion: reduce`. - **Sidebar component lost its private state.** Its own signal + effect + storage glue is gone; it delegates reads to the service and the toggle click to `layout.toggleSidebar()`. Net `sidebar.ts` shrunk by ~15 lines. ## Test plan - [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (25 / 25 specs, +6 for the new service spec + header zone tests). - [x] Production build under bundle budgets (100 kB gzip initial — +0.2 kB vs main). - [ ] Manual: load `/`, confirm the logo zone's right edge aligns exactly with the sidebar's right edge at both widths. - [ ] Manual: toggle the sidebar → both columns animate in sync; wordmark hides; logo glyph stays centered in the 4 rem zone. - [ ] Manual: reload after toggling → both header and sidebar restore to the persisted state. - [ ] Manual: `prefers-reduced-motion: reduce` → no width animation in either zone. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/85 --- .../src/app/components/header/header.html | 131 +++++++++--------- .../src/app/components/header/header.scss | 29 ++++ .../src/app/components/header/header.spec.ts | 27 ++++ .../src/app/components/header/header.ts | 16 ++- .../src/app/components/sidebar/sidebar.ts | 36 ++--- .../app/state/layout-state.service.spec.ts | 43 ++++++ .../src/app/state/layout-state.service.ts | 53 +++++++ 7 files changed, 243 insertions(+), 92 deletions(-) create mode 100644 apps/portal-shell/src/app/components/header/header.scss create mode 100644 apps/portal-shell/src/app/state/layout-state.service.spec.ts create mode 100644 apps/portal-shell/src/app/state/layout-state.service.ts diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html index e8a886f..8d6b684 100644 --- a/apps/portal-shell/src/app/components/header/header.html +++ b/apps/portal-shell/src/app/components/header/header.html @@ -1,65 +1,72 @@ -
- - - APF Portal - - - + +
+ + + +
diff --git a/apps/portal-shell/src/app/components/header/header.scss b/apps/portal-shell/src/app/components/header/header.scss new file mode 100644 index 0000000..ed24537 --- /dev/null +++ b/apps/portal-shell/src/app/components/header/header.scss @@ -0,0 +1,29 @@ +// Logo zone widths kept in lock-step with `sidebar.scss` (16rem +// expanded / 4rem collapsed). When the sidebar toggles, the rail +// below this zone reaches exactly the same boundary, so the brand +// stays flush with the navigation column. If those widths ever +// change, update both files together — extracting a shared SCSS +// variable would be the right move only if a third surface needs the +// same coupling. +.header__logo-zone { + display: flex; + align-items: center; + flex-shrink: 0; + width: 16rem; + height: 100%; + padding: 0 0.75rem; + border-right: 1px solid #e5e7eb; + transition: width 0.18s ease-out; + + &--collapsed { + width: 4rem; + padding: 0 0.5rem; + justify-content: center; + } +} + +@media (prefers-reduced-motion: reduce) { + .header__logo-zone { + transition: none; + } +} diff --git a/apps/portal-shell/src/app/components/header/header.spec.ts b/apps/portal-shell/src/app/components/header/header.spec.ts index 1054dd3..217df61 100644 --- a/apps/portal-shell/src/app/components/header/header.spec.ts +++ b/apps/portal-shell/src/app/components/header/header.spec.ts @@ -1,9 +1,11 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { Header } from './header'; +import { LayoutStateService } from '../../state/layout-state.service'; describe('Header', () => { beforeEach(async () => { + localStorage.clear(); await TestBed.configureTestingModule({ imports: [Header], providers: [provideRouter([])], @@ -45,4 +47,29 @@ describe('Header', () => { expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull(); expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull(); }); + + it('renders the logo zone, expanded with wordmark by default', async () => { + const fixture = TestBed.createComponent(Header); + fixture.detectChanges(); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + const zone = root.querySelector('.header__logo-zone'); + expect(zone).not.toBeNull(); + expect(zone?.classList.contains('header__logo-zone--collapsed')).toBe(false); + expect(root.textContent ?? '').toContain('APF Portal'); + }); + + it('collapses the logo zone and hides the wordmark when the sidebar is collapsed', async () => { + const layout = TestBed.inject(LayoutStateService); + layout.setSidebarCollapsed(true); + + const fixture = TestBed.createComponent(Header); + fixture.detectChanges(); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + const zone = root.querySelector('.header__logo-zone'); + expect(zone?.classList.contains('header__logo-zone--collapsed')).toBe(true); + const wordmark = root.querySelector('a[href="/"] span'); + expect(wordmark).toBeNull(); + }); }); diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts index cfcadae..37059fd 100644 --- a/apps/portal-shell/src/app/components/header/header.ts +++ b/apps/portal-shell/src/app/components/header/header.ts @@ -1,12 +1,17 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { Icon } from '../icon/icon'; +import { LayoutStateService } from '../../state/layout-state.service'; /** * Top-of-page banner. * * v1 layout (left → right): - * - Brand: APF logo + portal name, linking to "/". + * - Logo zone — sized to the sidebar rail so the brand sits flush + * above the navigation column. When the sidebar collapses, the + * logo zone shrinks too and the wordmark hides, leaving just the + * glyph centered above the icon-only rail. Driven by + * `LayoutStateService`. * - Global search input (placeholder; wired to a real search service * once we have content to query). * - Action cluster: notifications, help, settings, and an avatar @@ -20,6 +25,11 @@ import { Icon } from '../icon/icon'; selector: 'app-header', imports: [RouterLink, Icon], templateUrl: './header.html', + styleUrl: './header.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class Header {} +export class Header { + private readonly layout = inject(LayoutStateService); + + protected readonly collapsed = this.layout.sidebarCollapsed; +} diff --git a/apps/portal-shell/src/app/components/sidebar/sidebar.ts b/apps/portal-shell/src/app/components/sidebar/sidebar.ts index 46f3c93..a4661fb 100644 --- a/apps/portal-shell/src/app/components/sidebar/sidebar.ts +++ b/apps/portal-shell/src/app/components/sidebar/sidebar.ts @@ -1,6 +1,7 @@ -import { ChangeDetectionStrategy, Component, effect, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { Icon, type IconName } from '../icon/icon'; +import { LayoutStateService } from '../../state/layout-state.service'; /** * Primary side navigation. @@ -10,8 +11,9 @@ import { Icon, type IconName } from '../icon/icon'; * - collapsed: icon-only. ~64 px wide. Group titles hide; labels * remain in the accessible name of each link via `aria-label`. * - * The collapsed state is owned here and persisted in `localStorage` - * so a user's preference survives reloads (zero backend roundtrip). + * The collapsed state is owned by `LayoutStateService` (shell-level + * shared state) so the header's logo zone can size itself to match + * the sidebar rail. * * v1 menu is static and points to `#` placeholders — the live routes * land alongside the auth flow (ADR-0009). The shape is already @@ -60,8 +62,6 @@ const MENU: readonly MenuGroup[] = [ }, ]; -const STORAGE_KEY = 'portal-shell:sidebar-collapsed'; - @Component({ selector: 'app-sidebar', imports: [RouterLink, RouterLinkActive, Icon], @@ -70,30 +70,12 @@ const STORAGE_KEY = 'portal-shell:sidebar-collapsed'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class Sidebar { - protected readonly menu = MENU; - protected readonly collapsed = signal(readPersistedCollapsed()); + private readonly layout = inject(LayoutStateService); - constructor() { - effect(() => { - const value = this.collapsed(); - try { - localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); - } catch { - // localStorage can throw in private-mode contexts; the - // sidebar still works, the preference just doesn't persist. - } - }); - } + protected readonly menu = MENU; + protected readonly collapsed = this.layout.sidebarCollapsed; protected toggle(): void { - this.collapsed.update((v) => !v); - } -} - -function readPersistedCollapsed(): boolean { - try { - return localStorage.getItem(STORAGE_KEY) === '1'; - } catch { - return false; + this.layout.toggleSidebar(); } } diff --git a/apps/portal-shell/src/app/state/layout-state.service.spec.ts b/apps/portal-shell/src/app/state/layout-state.service.spec.ts new file mode 100644 index 0000000..767e19a --- /dev/null +++ b/apps/portal-shell/src/app/state/layout-state.service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; +import { LayoutStateService } from './layout-state.service'; + +describe('LayoutStateService', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + }); + + it('defaults to expanded when no preference is persisted', () => { + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(false); + }); + + it('initialises from the persisted preference', () => { + localStorage.setItem('portal-shell:sidebar-collapsed', '1'); + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(true); + }); + + it('toggleSidebar flips the value and persists it', () => { + const service = TestBed.inject(LayoutStateService); + expect(service.sidebarCollapsed()).toBe(false); + + service.toggleSidebar(); + expect(service.sidebarCollapsed()).toBe(true); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + + service.toggleSidebar(); + expect(service.sidebarCollapsed()).toBe(false); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0'); + }); + + it('setSidebarCollapsed writes the explicit value', () => { + const service = TestBed.inject(LayoutStateService); + service.setSidebarCollapsed(true); + expect(service.sidebarCollapsed()).toBe(true); + TestBed.tick(); + expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1'); + }); +}); diff --git a/apps/portal-shell/src/app/state/layout-state.service.ts b/apps/portal-shell/src/app/state/layout-state.service.ts new file mode 100644 index 0000000..549c77f --- /dev/null +++ b/apps/portal-shell/src/app/state/layout-state.service.ts @@ -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 `` so that `` 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; + } +}