diff --git a/apps/portal-shell/src/app/components/footer/footer.html b/apps/portal-shell/src/app/components/footer/footer.html
index 233d27e..78fd94e 100644
--- a/apps/portal-shell/src/app/components/footer/footer.html
+++ b/apps/portal-shell/src/app/components/footer/footer.html
@@ -3,10 +3,9 @@
aria-label="Page footer"
class="flex h-10 shrink-0 items-center justify-between border-t border-gray-200 bg-white px-4 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 sm:px-6"
>
-
-
+
+
© {{ year }} APF France handicap
+
·
+
+
diff --git a/apps/portal-shell/src/app/components/footer/footer.spec.ts b/apps/portal-shell/src/app/components/footer/footer.spec.ts
index 768d06c..e8aa9dc 100644
--- a/apps/portal-shell/src/app/components/footer/footer.spec.ts
+++ b/apps/portal-shell/src/app/components/footer/footer.spec.ts
@@ -42,4 +42,24 @@ describe('Footer', () => {
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-locale-switcher')).not.toBeNull();
});
+
+ it('embeds the theme switcher (moved here from the header)', async () => {
+ const fixture = TestBed.createComponent(Footer);
+ await fixture.whenStable();
+ expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
+ });
+
+ it('groups copyright + accessibility on the left, switchers on the right', async () => {
+ const fixture = TestBed.createComponent(Footer);
+ await fixture.whenStable();
+ const root = fixture.nativeElement as HTMLElement;
+ const clusters = Array.from(root.querySelectorAll('footer > div'));
+ expect(clusters).toHaveLength(2);
+ // Left cluster — info / legal.
+ expect(clusters[0]?.querySelector('p')?.textContent).toContain('APF France handicap');
+ expect(clusters[0]?.querySelector('a[href="/accessibility"]')).not.toBeNull();
+ // Right cluster — device preferences.
+ expect(clusters[1]?.querySelector('app-locale-switcher')).not.toBeNull();
+ expect(clusters[1]?.querySelector('app-theme-switcher')).not.toBeNull();
+ });
});
diff --git a/apps/portal-shell/src/app/components/footer/footer.ts b/apps/portal-shell/src/app/components/footer/footer.ts
index ea2d6c6..0479089 100644
--- a/apps/portal-shell/src/app/components/footer/footer.ts
+++ b/apps/portal-shell/src/app/components/footer/footer.ts
@@ -1,24 +1,30 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { LocaleSwitcher } from '../locale-switcher/locale-switcher';
+import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
/**
* Thin full-width footer pinned to the bottom of the shell.
*
- * v1 ships:
- * - APF France handicap copyright with the current year.
- * - Locale switcher (FR / EN) — per ADR-0019, hard-refreshes to
- * the matching `/fr/...` or `/en/...` prefix.
- * - Accessibility statement link — single i18n-marked link
- * pointing to the canonical `/accessibility` path.
+ * Layout — two clusters separated by `justify-between`:
+ * - **Left cluster** — APF France handicap copyright + accessibility
+ * statement link, joined by a typographic separator. Static
+ * informational content that anchors the page (who owns it, where
+ * to find legal info).
+ * - **Right cluster** — device preferences the reader controls:
+ * locale switcher (per ADR-0019) and theme switcher (light / dark
+ * / system). The theme switcher is in the footer rather than the
+ * header so the same trigger location works for anonymous and
+ * authenticated readers; moving it between auth states would
+ * break consistency for a control whose purpose (changing read
+ * conditions) is identical across both.
*
- * The footer is reserved for cross-cutting page furniture. A
- * dev-only version badge lands here once `SERVICE_VERSION` is wired
+ * A dev-only version badge lands here once `SERVICE_VERSION` is wired
* to a build-time injection.
*/
@Component({
selector: 'app-footer',
- imports: [RouterLink, LocaleSwitcher],
+ imports: [RouterLink, LocaleSwitcher, ThemeSwitcher],
templateUrl: './footer.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
diff --git a/apps/portal-shell/src/app/components/header/header.html b/apps/portal-shell/src/app/components/header/header.html
index 2c8631c..d8bc409 100644
--- a/apps/portal-shell/src/app/components/header/header.html
+++ b/apps/portal-shell/src/app/components/header/header.html
@@ -61,16 +61,6 @@
>
-
-
-
@switch (authState().kind) { @case ('loading') {
{
expect(label).not.toBeNull();
});
- it('renders the notifications / help / settings action buttons', async () => {
+ it('renders the notifications / help action buttons', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
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();
});
- it('embeds the theme switcher', async () => {
+ it('no longer embeds the theme switcher (moved to the footer)', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
- expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
+ expect(fixture.nativeElement.querySelector('app-theme-switcher')).toBeNull();
+ });
+
+ it('no longer embeds a standalone Settings button (UserMenu carries it)', async () => {
+ const { fixture } = await setup({ onBootstrap: 'anonymous' });
+ expect(
+ (fixture.nativeElement as HTMLElement).querySelector('button[aria-label="Settings"]'),
+ ).toBeNull();
});
it('renders the logo zone, expanded with wordmark by default', async () => {
diff --git a/apps/portal-shell/src/app/components/header/header.ts b/apps/portal-shell/src/app/components/header/header.ts
index d06be96..8334df7 100644
--- a/apps/portal-shell/src/app/components/header/header.ts
+++ b/apps/portal-shell/src/app/components/header/header.ts
@@ -5,7 +5,6 @@ import { Icon, UserMenu, type UserMenuItem } from 'shared-ui';
import { LayoutStateService } from 'shared-state';
import { environment } from '../../../environments/environment';
import { CapabilitiesService } from '../../services/capabilities.service';
-import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
/**
* Top-of-page banner.
@@ -18,18 +17,19 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
* `LayoutStateService`.
* - Global search input (placeholder; wired to a real search service
* once we have content to query).
- * - Action cluster: notifications, help, settings, and a user
- * widget. The widget renders one of three states — loading,
- * anonymous (a "Sign in" button), authenticated (avatar with
- * initials + display name + "Sign out" button) — driven by
- * `AuthService` (ADR-0009).
+ * - Action cluster: notifications, help, and the user widget. The
+ * widget renders one of three states — loading, anonymous
+ * (a "Sign in" button), authenticated (avatar dropdown) — driven
+ * by `AuthService` (ADR-0009). Theme + locale switchers live in
+ * the footer alongside the accessibility statement so a single
+ * "device-prefs" cluster carries them across auth states.
*
* 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, Icon, ThemeSwitcher, UserMenu],
+ imports: [RouterLink, Icon, UserMenu],
templateUrl: './header.html',
styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
diff --git a/apps/portal-shell/src/locale/messages.fr.xlf b/apps/portal-shell/src/locale/messages.fr.xlf
index 3f2a71e..dfd89f6 100644
--- a/apps/portal-shell/src/locale/messages.fr.xlf
+++ b/apps/portal-shell/src/locale/messages.fr.xlf
@@ -44,10 +44,6 @@
Notifications
Notifications
-