diff --git a/apps/portal-shell/public/favicon.ico b/apps/portal-shell/public/favicon.ico
index 317ebcb..696fec8 100644
Binary files a/apps/portal-shell/public/favicon.ico and b/apps/portal-shell/public/favicon.ico differ
diff --git a/apps/portal-shell/public/logos/apf-portal.svg b/apps/portal-shell/public/logos/apf-portal.svg
new file mode 100644
index 0000000..a0516bd
--- /dev/null
+++ b/apps/portal-shell/public/logos/apf-portal.svg
@@ -0,0 +1,12 @@
+
diff --git a/apps/portal-shell/src/app/app.html b/apps/portal-shell/src/app/app.html
index 9467553..bd16644 100644
--- a/apps/portal-shell/src/app/app.html
+++ b/apps/portal-shell/src/app/app.html
@@ -1,6 +1,8 @@
Skip to main content
-
-
-
-
+
diff --git a/apps/portal-shell/src/app/app.scss b/apps/portal-shell/src/app/app.scss
index cee7709..d8810e0 100644
--- a/apps/portal-shell/src/app/app.scss
+++ b/apps/portal-shell/src/app/app.scss
@@ -1,10 +1,26 @@
-// Page-level layout: header + main + footer in a flex column so the
-// footer sticks to the viewport bottom even when the main content is
-// short. Driven on the host so it covers the whole document.
+// App shell: fixed-height header on top, sidebar + main beside each
+// other below, both filling the remaining viewport height. The sidebar
+// owns its own scrolling (long menus don't push the header off-screen);
+// the main pane scrolls independently so opening a long page never
+// scrolls the sidebar out of view.
:host {
display: flex;
flex-direction: column;
min-height: 100vh;
+ height: 100vh;
+}
+
+.shell-body {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.shell-main {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-y: auto;
+ background-color: #f9fafb;
}
// Skip-link (WCAG 2.4.1 "Bypass Blocks"). Hidden by default, fully
@@ -17,7 +33,7 @@
left: 0.75rem;
z-index: 50;
padding: 0.5rem 0.75rem;
- background: #1d4ed8;
+ background: var(--color-brand-primary-500);
color: #fff;
border-radius: 0.25rem;
text-decoration: none;
@@ -26,7 +42,7 @@
&:focus,
&:focus-visible {
top: 0.5rem;
- outline: 2px solid #93c5fd;
+ outline: 2px solid var(--color-brand-accent-300);
outline-offset: 2px;
}
}
diff --git a/apps/portal-shell/src/app/app.spec.ts b/apps/portal-shell/src/app/app.spec.ts
index b5e71e6..1e3cd49 100644
--- a/apps/portal-shell/src/app/app.spec.ts
+++ b/apps/portal-shell/src/app/app.spec.ts
@@ -10,13 +10,13 @@ describe('App', () => {
}).compileComponents();
});
- it('renders the layout shell — skip link, header, main, footer', async () => {
+ it('renders the layout shell — skip link, header, sidebar, main', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('a.skip-link')).not.toBeNull();
expect(root.querySelector('app-header')).not.toBeNull();
+ expect(root.querySelector('app-sidebar')).not.toBeNull();
expect(root.querySelector('main#main-content')).not.toBeNull();
- expect(root.querySelector('app-footer')).not.toBeNull();
});
});
diff --git a/apps/portal-shell/src/app/app.ts b/apps/portal-shell/src/app/app.ts
index 5617ede..5f82fdd 100644
--- a/apps/portal-shell/src/app/app.ts
+++ b/apps/portal-shell/src/app/app.ts
@@ -1,11 +1,11 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Header } from './components/header/header';
-import { Footer } from './components/footer/footer';
+import { Sidebar } from './components/sidebar/sidebar';
@Component({
selector: 'app-root',
- imports: [RouterOutlet, Header, Footer],
+ imports: [RouterOutlet, Header, Sidebar],
templateUrl: './app.html',
styleUrl: './app.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
diff --git a/apps/portal-shell/src/app/components/footer/footer.html b/apps/portal-shell/src/app/components/footer/footer.html
deleted file mode 100644
index f8ea508..0000000
--- a/apps/portal-shell/src/app/components/footer/footer.html
+++ /dev/null
@@ -1,25 +0,0 @@
-
diff --git a/apps/portal-shell/src/app/components/footer/footer.spec.ts b/apps/portal-shell/src/app/components/footer/footer.spec.ts
deleted file mode 100644
index abffa02..0000000
--- a/apps/portal-shell/src/app/components/footer/footer.spec.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { TestBed } from '@angular/core/testing';
-import { provideRouter } from '@angular/router';
-import { Footer } from './footer';
-
-describe('Footer', () => {
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [Footer],
- providers: [provideRouter([])],
- }).compileComponents();
- });
-
- it('renders both accessibility statement links (FR + EN)', async () => {
- const fixture = TestBed.createComponent(Footer);
- await fixture.whenStable();
- const root = fixture.nativeElement as HTMLElement;
- expect(root.querySelector('a[href="/accessibility"]')).not.toBeNull();
- expect(root.querySelector('a[href="/accessibilite"]')).not.toBeNull();
- });
-
- it('shows the version badge', async () => {
- const fixture = TestBed.createComponent(Footer);
- await fixture.whenStable();
- const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
- expect(text).toContain('vdev');
- });
-});
diff --git a/apps/portal-shell/src/app/components/footer/footer.ts b/apps/portal-shell/src/app/components/footer/footer.ts
deleted file mode 100644
index 91db109..0000000
--- a/apps/portal-shell/src/app/components/footer/footer.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
-import { RouterLink } from '@angular/router';
-
-/**
- * Bottom-of-page utility links. v1 ships:
- * - Accessibility statement link (RGAA-mandated, both locales) per
- * ADR-0016.
- * - Build/version badge for support triage.
- *
- * Same app-level placement as the header — promotion to
- * libs/shared/ui/ when a second consumer materialises.
- */
-@Component({
- selector: 'app-footer',
- imports: [RouterLink],
- templateUrl: './footer.html',
- changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class Footer {
- // Hard-coded for now; swapped to a build-time injection once the
- // env-config story (Angular `environment.ts`) lands.
- protected readonly version = 'dev';
-}
diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html
index 100ec4b..e8a886f 100644
--- a/apps/portal-shell/src/app/components/header/header.html
+++ b/apps/portal-shell/src/app/components/header/header.html
@@ -1,11 +1,65 @@
-
-
+
+
+
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 b49c058..1054dd3 100644
--- a/apps/portal-shell/src/app/components/header/header.spec.ts
+++ b/apps/portal-shell/src/app/components/header/header.spec.ts
@@ -15,7 +15,7 @@ describe('Header', () => {
await fixture.whenStable();
const link = fixture.nativeElement.querySelector('a[href="/"]') as HTMLAnchorElement | null;
expect(link).not.toBeNull();
- expect(link?.textContent?.trim()).toBe('APF Portal');
+ expect(link?.textContent?.trim()).toContain('APF Portal');
});
it('exposes a primary navigation landmark', async () => {
@@ -24,4 +24,25 @@ describe('Header', () => {
const nav = fixture.nativeElement.querySelector('nav[aria-label="Primary"]');
expect(nav).not.toBeNull();
});
+
+ it('exposes a search landmark with a labelled input', async () => {
+ const fixture = TestBed.createComponent(Header);
+ await fixture.whenStable();
+ const root = fixture.nativeElement as HTMLElement;
+ expect(root.querySelector('form[role="search"]')).not.toBeNull();
+ const input = root.querySelector('input#global-search') as HTMLInputElement | null;
+ expect(input).not.toBeNull();
+ expect(input?.type).toBe('search');
+ const label = root.querySelector('label[for="global-search"]');
+ expect(label).not.toBeNull();
+ });
+
+ it('renders the notifications / help / settings action buttons', async () => {
+ const fixture = TestBed.createComponent(Header);
+ await fixture.whenStable();
+ const root = fixture.nativeElement as HTMLElement;
+ expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
+ expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull();
+ expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull();
+ });
});
diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts
index 7588b85..cfcadae 100644
--- a/apps/portal-shell/src/app/components/header/header.ts
+++ b/apps/portal-shell/src/app/components/header/header.ts
@@ -1,18 +1,24 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
+import { Icon } from '../icon/icon';
/**
- * Top-of-page banner. v1 holds only the brand link to "/" and the
- * primary navigation landmark — the live navigation grows once we
- * have real authenticated pages (per ADR-0009).
+ * Top-of-page banner.
*
- * Lives at app level (not in libs/shared/ui/) on purpose: there is
- * one consumer (portal-shell). Promotion to the shared lib happens
- * when a second app needs it.
+ * v1 layout (left → right):
+ * - Brand: APF logo + portal name, linking to "/".
+ * - Global search input (placeholder; wired to a real search service
+ * once we have content to query).
+ * - Action cluster: notifications, help, settings, and an avatar
+ * placeholder. Real menus are added once the auth flow lands
+ * (ADR-0009).
+ *
+ * Lives at app level (not in libs/shared/ui/) on purpose: one consumer
+ * for now. Promotion when a second app needs it.
*/
@Component({
selector: 'app-header',
- imports: [RouterLink],
+ imports: [RouterLink, Icon],
templateUrl: './header.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
diff --git a/apps/portal-shell/src/app/components/icon/icon.spec.ts b/apps/portal-shell/src/app/components/icon/icon.spec.ts
new file mode 100644
index 0000000..fa8f1de
--- /dev/null
+++ b/apps/portal-shell/src/app/components/icon/icon.spec.ts
@@ -0,0 +1,49 @@
+import { Component } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { Icon } from './icon';
+
+@Component({
+ selector: 'app-icon-test-host',
+ imports: [Icon],
+ template: ``,
+})
+class IconTestHost {
+ name: 'home' | 'bell' = 'home';
+ size = 20;
+ strokeWidth = 1.5;
+}
+
+describe('Icon', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({ imports: [IconTestHost] }).compileComponents();
+ });
+
+ it('renders a lucide