feat(portal-shell): move theme switcher to footer, drop redundant settings icon
CI / scan (pull_request) Successful in 2m36s
CI / commits (pull_request) Successful in 2m49s
CI / check (pull_request) Successful in 2m56s
CI / a11y (pull_request) Successful in 1m52s
CI / perf (pull_request) Successful in 4m10s

Three coupled chrome adjustments — all in `portal-shell`'s shell
layout, all motivated by the same realisation that the user-menu
introduced in #149 made the header's standalone Settings icon
redundant and surfaced an inconsistency in the theme switcher's
placement.

* **Drop the standalone Settings button** from the header. The
  UserMenu already carries a "Settings (Soon)" row for authenticated
  users, and Settings has no meaning for anonymous traffic. The
  icon pointed nowhere; carrying it doubled the surface and forced
  readers to guess which control to use. The `header.action.settings`
  i18n unit is removed from `messages.fr.xlf` in the same step so
  the prod build's `i18nMissingTranslation=error` policy stays
  consistent.

* **Move `<app-theme-switcher>` from the header to the footer.** It
  now sits beside `<app-locale-switcher>` in a single "device-prefs"
  cluster on the right side of the footer. The alternative
  considered — keeping it in the header for anonymous traffic and
  moving it into the user menu once authenticated — was rejected:
  the trigger location for an identical control (changing read
  conditions) must not depend on auth state, especially for an
  a11y-first platform per ADR-0016. A reader who relies on dark
  mode shouldn't have to expand a menu they don't recognise on
  first visit to escape a flashing white background. The footer
  matches what ADR-0019 already established for the locale
  switcher (ambient device preferences live there).

* **Reshape the footer into two semantic clusters.** The
  Accessibility statement link moves from the right (preferences)
  cluster to the left, joining the copyright with a typographic
  separator (`·`). Left = static info / legal anchor ("who owns
  this, where to find legal info"); right = interactive prefs
  ("what I control about this page"). Keyboard order naturally
  follows: a Tab-traversal from main content hits info before
  interactive controls.

Specs updated:
  - Header spec swaps "renders Settings button" / "embeds theme
    switcher" assertions for the absence guards that lock the
    new layout.
  - Footer spec adds a theme-switcher assertion and a cluster-grouping
    assertion that pins copyright + accessibility to the first
    direct-child div, locale + theme to the second.

Verification: `pnpm nx run-many -t lint test build --projects=portal-shell`
green, 43 portal-shell specs (was 40); the i18n-strict production
build (`portal-shell:build:production`) passes too — confirms the
xlf cleanup didn't strand any source-locale reference.
This commit is contained in:
Julien Gautier
2026-05-16 01:06:34 +02:00
parent 2dbf2d8ce4
commit a8ddb0f63d
7 changed files with 60 additions and 38 deletions
@@ -3,10 +3,9 @@
aria-label="Page footer" 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" 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"
> >
<div class="flex items-center gap-3">
<p i18n="@@footer.copyright">&copy; {{ year }} APF France handicap</p> <p i18n="@@footer.copyright">&copy; {{ year }} APF France handicap</p>
<span aria-hidden="true" class="text-gray-300 dark:text-gray-700">·</span>
<div class="flex items-center gap-4">
<app-locale-switcher />
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal"> <nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
<a <a
routerLink="/accessibility" routerLink="/accessibility"
@@ -16,4 +15,9 @@
> >
</nav> </nav>
</div> </div>
<div class="flex items-center gap-4">
<app-locale-switcher />
<app-theme-switcher />
</div>
</footer> </footer>
@@ -42,4 +42,24 @@ describe('Footer', () => {
await fixture.whenStable(); await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-locale-switcher')).not.toBeNull(); 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();
});
}); });
@@ -1,24 +1,30 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { LocaleSwitcher } from '../locale-switcher/locale-switcher'; 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. * Thin full-width footer pinned to the bottom of the shell.
* *
* v1 ships: * Layout — two clusters separated by `justify-between`:
* - APF France handicap copyright with the current year. * - **Left cluster** — APF France handicap copyright + accessibility
* - Locale switcher (FR / EN) — per ADR-0019, hard-refreshes to * statement link, joined by a typographic separator. Static
* the matching `/fr/...` or `/en/...` prefix. * informational content that anchors the page (who owns it, where
* - Accessibility statement link — single i18n-marked link * to find legal info).
* pointing to the canonical `/accessibility` path. * - **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 * A dev-only version badge lands here once `SERVICE_VERSION` is wired
* dev-only version badge lands here once `SERVICE_VERSION` is wired
* to a build-time injection. * to a build-time injection.
*/ */
@Component({ @Component({
selector: 'app-footer', selector: 'app-footer',
imports: [RouterLink, LocaleSwitcher], imports: [RouterLink, LocaleSwitcher, ThemeSwitcher],
templateUrl: './footer.html', templateUrl: './footer.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
@@ -61,16 +61,6 @@
> >
<lib-icon name="circle-help" /> <lib-icon name="circle-help" />
</button> </button>
<app-theme-switcher />
<button
type="button"
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
i18n-aria-label="@@header.action.settings"
aria-label="Settings"
>
<lib-icon name="settings" />
</button>
@switch (authState().kind) { @case ('loading') { @switch (authState().kind) { @case ('loading') {
<span <span
aria-hidden="true" aria-hidden="true"
@@ -96,17 +96,23 @@ describe('Header', () => {
expect(label).not.toBeNull(); 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 { fixture } = await setup({ onBootstrap: 'anonymous' });
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull(); expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
expect(root.querySelector('button[aria-label="Help"]')).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' }); 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 () => { it('renders the logo zone, expanded with wordmark by default', async () => {
@@ -5,7 +5,6 @@ import { Icon, UserMenu, type UserMenuItem } from 'shared-ui';
import { LayoutStateService } from 'shared-state'; import { LayoutStateService } from 'shared-state';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { CapabilitiesService } from '../../services/capabilities.service'; import { CapabilitiesService } from '../../services/capabilities.service';
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
/** /**
* Top-of-page banner. * Top-of-page banner.
@@ -18,18 +17,19 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
* `LayoutStateService`. * `LayoutStateService`.
* - Global search input (placeholder; wired to a real search service * - Global search input (placeholder; wired to a real search service
* once we have content to query). * once we have content to query).
* - Action cluster: notifications, help, settings, and a user * - Action cluster: notifications, help, and the user widget. The
* widget. The widget renders one of three states — loading, * widget renders one of three states — loading, anonymous
* anonymous (a "Sign in" button), authenticated (avatar with * (a "Sign in" button), authenticated (avatar dropdown) — driven
* initials + display name + "Sign out" button) — driven by * by `AuthService` (ADR-0009). Theme + locale switchers live in
* `AuthService` (ADR-0009). * 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 * Lives at app level (not in libs/shared/ui/) on purpose: one consumer
* for now. Promotion when a second app needs it. * for now. Promotion when a second app needs it.
*/ */
@Component({ @Component({
selector: 'app-header', selector: 'app-header',
imports: [RouterLink, Icon, ThemeSwitcher, UserMenu], imports: [RouterLink, Icon, UserMenu],
templateUrl: './header.html', templateUrl: './header.html',
styleUrl: './header.scss', styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -44,10 +44,6 @@
<source>Notifications</source> <source>Notifications</source>
<target>Notifications</target> <target>Notifications</target>
</trans-unit> </trans-unit>
<trans-unit id="header.action.settings" datatype="html">
<source>Settings</source>
<target>Paramètres</target>
</trans-unit>
<trans-unit id="header.authError" datatype="html"> <trans-unit id="header.authError" datatype="html">
<source>Can't reach the server</source> <source>Can't reach the server</source>
<target>Serveur injoignable</target> <target>Serveur injoignable</target>