feat(portal-shell): i18n string sweep — mark UI strings + FR translations (#93)
CI / check (push) Successful in 3m20s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 1m45s
CI / a11y (push) Successful in 1m37s
CI / perf (push) Successful in 3m11s

## Summary

Continue ADR-0019 implementation. Mark every UI string surfaced by the shell with `i18n="@@id"` (templates) or `$localize` (TypeScript), and populate [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) with French translations. The production build now ships **two genuinely different bundles** under `dist/.../{en,fr}/`.

**43 trans-units** marked, grouped by feature:

| Surface | Strings |
|---|---|
| `app.html` | skip-link |
| `header.html` | wordmark, search label + placeholder, action button aria-labels, user-menu placeholder |
| `sidebar.ts` + `.html` | menu groups + items, aside / nav aria-labels, role badge, toggle button (Expand / Collapse aria + Collapse text) |
| `theme-switcher.ts` + `.html` | mode labels, menu aria, trigger aria (`Theme: <mode> (open menu)` with named placeholder) |
| `footer.html` | aria-labels, copyright (interpolation preserved) |
| `home.html` | welcome heading + intro + status widget labels |
| `app.routes.ts` | browser tab titles |

## Tooling

- Add `"@angular/localize"` to the `types` array in both [`tsconfig.app.json`](apps/portal-shell/tsconfig.app.json) and [`tsconfig.spec.json`](apps/portal-shell/tsconfig.spec.json) so TypeScript resolves the `$localize` global at compile time. Specs need it too — they evaluate the same component code paths.
- Extraction target (`nx run portal-shell:extract-i18n`) reports **44 messages** (43 unique IDs).

## Translation choices worth flagging

- **Wordmark**: "APF Portal" → "Portail APF". Same key used for the `/` browser tab title. The PWA manifest (`site.webmanifest`) stays "APF Portal" — manifest values are not bundled, they sit in the static assets and are language-neutral in v1.
- **System theme mode** → "Système".
- **"Anonymous"** role → "Anonyme"; **"Role:"** → **"Rôle :"** (French uses a non-breaking space before the colon — typographic convention, preserved in the XLIFF target).
- **Accessibility links in the footer stay bilingual.** Each carries its own `lang` attribute (`lang="en"` and `lang="fr"`). The dual-link pattern goes away in the upcoming route-fusion PR; until then it's the most honest stopgap.
- **Both accessibility routes share one title key** (`@@route.accessibility.title`). In the EN bundle, both display "Accessibility statement · APF Portal"; in the FR bundle, both display "Déclaration d'accessibilité · Portail APF". After route fusion only one route remains.

## Verification

- Production build: **129 kB gzip initial per locale** (vs 122 kB before). +7 kB absorbs the i18n marker metadata and the embedded translation data in the FR bundle. Well under the 300 KB budget.
- Spot-checked the FR bundle: no leftover English source text on any swept string, route title, or home page intro. The `Welcome to APF Portal` in the lazy `home` chunk shows "Bienvenue sur Portail APF" in FR.
- **36 / 36 specs unchanged.** They run in the source locale (`en`), so the English assertions still match. No spec edits needed.
- Lint clean.

## Out of scope (each its own follow-up PR)

- **Locale switcher in the footer** + `__Host-portal_locale` cookie + smart `/` redirect.
- **Collapse `/accessibility` + `/accessibilite`** into one localised route with locale-translated path segments.
- **CI gate** that fails the build on a missing translation. Once the sweep is reviewed, we add `nx build --localize` to `ci:check` and verify it rejects unsealed strings.
- **Accessibility page content localisation.** Its content is driven by a `copy()` service rather than i18n-marked templates — restructured during the route fusion.

## Test plan

- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs).
- [x] `pnpm exec nx run portal-shell:extract-i18n` — clean, 43 unique unit IDs.
- [x] Production build emits both locales; FR bundle contains "Tableau de bord", "Aller au contenu principal", etc.
- [ ] Manual: `pnpm exec nx serve portal-shell` shows the EN source. `pnpm exec nx build portal-shell --configuration=production && pnpm exec nx run portal-shell:serve-static` → open `http://localhost:4200/fr/` for FR, `/en/` for EN.
- [ ] Manual: every aria-label announced by a screen reader in FR build matches the French translation.
- [ ] Manual: browser tab title flips between bundles ("APF Portal" vs "Portail APF").

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #93
This commit was merged in pull request #93.
This commit is contained in:
2026-05-11 19:54:43 +02:00
parent 04675b1b59
commit 65fae7f963
12 changed files with 312 additions and 56 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
<a class="skip-link" href="#main-content">Skip to main content</a> <a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
<app-header /> <app-header />
<div class="shell-body"> <div class="shell-body">
<app-sidebar /> <app-sidebar />
+10 -3
View File
@@ -1,11 +1,18 @@
import { Route } from '@angular/router'; import { Route } from '@angular/router';
// Tab titles per route — marked with `$localize` so each locale's
// bundle ships its own. The `/accessibility` and `/accessibilite`
// pair shares a title key; the upcoming route fusion (per ADR-0019)
// collapses them to a single localised route.
const homeTitle = $localize`:@@route.home.title:APF Portal`;
const accessibilityTitle = $localize`:@@route.accessibility.title:Accessibility statement · APF Portal`;
export const appRoutes: Route[] = [ export const appRoutes: Route[] = [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
loadComponent: () => import('./pages/home/home').then((m) => m.Home), loadComponent: () => import('./pages/home/home').then((m) => m.Home),
title: 'APF Portal', title: homeTitle,
}, },
// RGAA + ADR-0016: the accessibility statement must be reachable // RGAA + ADR-0016: the accessibility statement must be reachable
// from both locales' canonical paths. Same component, content // from both locales' canonical paths. Same component, content
@@ -15,13 +22,13 @@ export const appRoutes: Route[] = [
loadComponent: () => loadComponent: () =>
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement), import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
data: { lang: 'en' }, data: { lang: 'en' },
title: 'Accessibility statement · APF Portal', title: accessibilityTitle,
}, },
{ {
path: 'accessibilite', path: 'accessibilite',
loadComponent: () => loadComponent: () =>
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement), import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
data: { lang: 'fr' }, data: { lang: 'fr' },
title: 'Déclaration daccessibilité · APF Portal', title: accessibilityTitle,
}, },
]; ];
@@ -1,14 +1,16 @@
<footer <footer
i18n-aria-label="@@footer.aria.label"
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"
> >
<p>&copy; {{ year }} APF France handicap</p> <p i18n="@@footer.copyright">&copy; {{ year }} APF France handicap</p>
<nav aria-label="Legal"> <nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
<ul class="flex items-center gap-4"> <ul class="flex items-center gap-4">
<li> <li>
<a <a
routerLink="/accessibility" routerLink="/accessibility"
lang="en"
class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300" class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300"
>Accessibility</a >Accessibility</a
> >
@@ -17,6 +19,7 @@
<li> <li>
<a <a
routerLink="/accessibilite" routerLink="/accessibilite"
lang="fr"
class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300" class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300"
>Accessibilité</a >Accessibilité</a
> >
@@ -16,14 +16,16 @@
class="h-9 w-9 shrink-0" class="h-9 w-9 shrink-0"
/> />
@if (!collapsed()) { @if (!collapsed()) {
<span class="text-base font-semibold sm:text-lg">APF Portal</span> <span class="text-base font-semibold sm:text-lg" i18n="@@header.wordmark">APF Portal</span>
} }
</a> </a>
</div> </div>
<div class="flex flex-1 items-center gap-4 px-4 sm:px-6"> <div class="flex flex-1 items-center gap-4 px-4 sm:px-6">
<form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()"> <form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()">
<label class="sr-only" for="global-search">Search the portal</label> <label class="sr-only" for="global-search" i18n="@@header.search.label"
>Search the portal</label
>
<div <div
class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700" class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700"
> >
@@ -31,6 +33,7 @@
<input <input
id="global-search" id="global-search"
type="search" type="search"
i18n-placeholder="@@header.search.placeholder"
placeholder="Search…" placeholder="Search…"
class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500" class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500"
/> />
@@ -41,6 +44,7 @@
<button <button
type="button" type="button"
class="relative 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" class="relative 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.notifications"
aria-label="Notifications" aria-label="Notifications"
> >
<app-icon name="bell" /> <app-icon name="bell" />
@@ -52,6 +56,7 @@
<button <button
type="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" 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.help"
aria-label="Help" aria-label="Help"
> >
<app-icon name="circle-help" /> <app-icon name="circle-help" />
@@ -60,11 +65,13 @@
<button <button
type="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" 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" aria-label="Settings"
> >
<app-icon name="settings" /> <app-icon name="settings" />
</button> </button>
<span <span
i18n-aria-label="@@header.action.userMenu"
aria-label="User menu (coming soon)" aria-label="User menu (coming soon)"
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white" class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
> >
@@ -1,9 +1,14 @@
<aside <aside
class="sidebar flex h-full flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900" class="sidebar flex h-full flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
[class.sidebar--collapsed]="collapsed()" [class.sidebar--collapsed]="collapsed()"
[attr.aria-label]="'Sidebar navigation'" i18n-aria-label="@@sidebar.aria.label"
aria-label="Sidebar navigation"
> >
<nav aria-label="Sections" class="flex-1 overflow-y-auto px-2 py-4"> <nav
i18n-aria-label="@@sidebar.aria.sections"
aria-label="Sections"
class="flex-1 overflow-y-auto px-2 py-4"
>
@for (group of menu; track group.label) { @for (group of menu; track group.label) {
<div class="mb-4"> <div class="mb-4">
@if (!collapsed()) { @if (!collapsed()) {
@@ -55,10 +60,13 @@
@if (!collapsed()) { @if (!collapsed()) {
<p <p
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400" class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
i18n-aria-label="@@sidebar.role.aria"
aria-label="Current role: anonymous (signed-out)" aria-label="Current role: anonymous (signed-out)"
> >
Role: <ng-container i18n="@@sidebar.role.label">Role:</ng-container>
<span class="font-medium text-gray-700 dark:text-gray-200">Anonymous</span> <span class="font-medium text-gray-700 dark:text-gray-200" i18n="@@sidebar.role.anonymous"
>Anonymous</span
>
</p> </p>
} }
@@ -67,13 +75,11 @@
(click)="toggle()" (click)="toggle()"
class="sidebar__toggle" class="sidebar__toggle"
[attr.aria-expanded]="!collapsed()" [attr.aria-expanded]="!collapsed()"
[attr.aria-label]=" [attr.aria-label]="toggleAriaLabel()"
collapsed() ? 'Expand sidebar' : 'Collapse sidebar'
"
> >
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" /> <app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
@if (!collapsed()) { @if (!collapsed()) {
<span class="sidebar__label">Collapse</span> <span class="sidebar__label" i18n="@@sidebar.toggle.collapse">Collapse</span>
} }
</button> </button>
</div> </div>
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { Icon, type IconName } from '../icon/icon'; import { Icon, type IconName } from '../icon/icon';
import { LayoutStateService } from '../../state/layout-state.service'; import { LayoutStateService } from '../../state/layout-state.service';
@@ -21,6 +21,11 @@ import { LayoutStateService } from '../../state/layout-state.service';
* plug in a filter without restructuring the data. The Dashboard * plug in a filter without restructuring the data. The Dashboard
* entry uses `routerLink="/"` so the active-state styling is visible * entry uses `routerLink="/"` so the active-state styling is visible
* on the home page; the rest are anchors until real routes exist. * on the home page; the rest are anchors until real routes exist.
*
* String localisation: the menu data uses `$localize` tagged template
* literals (per ADR-0019). The literals evaluate at module load — the
* `@angular/localize/init` polyfill is loaded before the bundle so
* the active-locale value is resolved by the time MENU is built.
*/ */
export interface MenuItem { export interface MenuItem {
@@ -40,24 +45,28 @@ export interface MenuGroup {
const MENU: readonly MenuGroup[] = [ const MENU: readonly MenuGroup[] = [
{ {
label: 'General', label: $localize`:@@sidebar.group.general:General`,
items: [ items: [
{ label: 'Dashboard', icon: 'layout-dashboard', routerLink: '/' }, {
{ label: 'Calendar', icon: 'calendar', href: '#' }, label: $localize`:@@sidebar.item.dashboard:Dashboard`,
icon: 'layout-dashboard',
routerLink: '/',
},
{ label: $localize`:@@sidebar.item.calendar:Calendar`, icon: 'calendar', href: '#' },
], ],
}, },
{ {
label: 'Establishments', label: $localize`:@@sidebar.group.establishments:Establishments`,
items: [ items: [
{ label: 'Directory', icon: 'building-2', href: '#' }, { label: $localize`:@@sidebar.item.directory:Directory`, icon: 'building-2', href: '#' },
{ label: 'Activities', icon: 'folder', href: '#' }, { label: $localize`:@@sidebar.item.activities:Activities`, icon: 'folder', href: '#' },
], ],
}, },
{ {
label: 'Communication', label: $localize`:@@sidebar.group.communication:Communication`,
items: [ items: [
{ label: 'Messages', icon: 'mail', href: '#' }, { label: $localize`:@@sidebar.item.messages:Messages`, icon: 'mail', href: '#' },
{ label: 'Documents', icon: 'file-text', href: '#' }, { label: $localize`:@@sidebar.item.documents:Documents`, icon: 'file-text', href: '#' },
], ],
}, },
]; ];
@@ -75,6 +84,12 @@ export class Sidebar {
protected readonly menu = MENU; protected readonly menu = MENU;
protected readonly collapsed = this.layout.sidebarCollapsed; protected readonly collapsed = this.layout.sidebarCollapsed;
protected readonly toggleAriaLabel = computed(() =>
this.collapsed()
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
: $localize`:@@sidebar.toggle.aria.collapse:Collapse sidebar`,
);
protected toggle(): void { protected toggle(): void {
this.layout.toggleSidebar(); this.layout.toggleSidebar();
} }
@@ -2,13 +2,19 @@
type="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" 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"
[cdkMenuTriggerFor]="themeMenu" [cdkMenuTriggerFor]="themeMenu"
[attr.aria-label]="'Theme: ' + currentLabel() + ' (open menu)'" [attr.aria-label]="triggerAriaLabel()"
> >
<app-icon [name]="currentIcon()" /> <app-icon [name]="currentIcon()" />
</button> </button>
<ng-template #themeMenu> <ng-template #themeMenu>
<div cdkMenu class="theme-switcher__menu" role="menu" aria-label="Theme"> <div
cdkMenu
class="theme-switcher__menu"
role="menu"
i18n-aria-label="@@theme.menu.aria"
aria-label="Theme"
>
@for (option of options; track option.mode) { @for (option of options; track option.mode) {
<button <button
type="button" type="button"
@@ -31,9 +31,9 @@ interface ModeOption {
} }
const MODE_OPTIONS: readonly ModeOption[] = [ const MODE_OPTIONS: readonly ModeOption[] = [
{ mode: 'light', label: 'Light', icon: 'sun' }, { mode: 'light', label: $localize`:@@theme.mode.light:Light`, icon: 'sun' },
{ mode: 'dark', label: 'Dark', icon: 'moon' }, { mode: 'dark', label: $localize`:@@theme.mode.dark:Dark`, icon: 'moon' },
{ mode: 'auto', label: 'System', icon: 'monitor' }, { mode: 'auto', label: $localize`:@@theme.mode.system:System`, icon: 'monitor' },
]; ];
@Component({ @Component({
@@ -60,7 +60,12 @@ export class ThemeSwitcher {
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.icon ?? 'monitor', () => MODE_OPTIONS.find((o) => o.mode === this.mode())?.icon ?? 'monitor',
); );
protected readonly currentLabel = computed( protected readonly currentLabel = computed(
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ?? 'System', () =>
MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ??
$localize`:@@theme.mode.system:System`,
);
protected readonly triggerAriaLabel = computed(
() => $localize`:@@theme.trigger.aria:Theme: ${this.currentLabel()}:mode: (open menu)`,
); );
protected select(mode: ThemeMode): void { protected select(mode: ThemeMode): void {
+30 -10
View File
@@ -1,8 +1,14 @@
<section class="mx-auto max-w-3xl px-6 py-12"> <section class="mx-auto max-w-3xl px-6 py-12">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"> <h1
class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
i18n="@@page.home.title"
>
Welcome to APF Portal Welcome to APF Portal
</h1> </h1>
<p class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300"> <p
class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300"
i18n="@@page.home.intro"
>
This is the placeholder home page for the APF Portal. Real content lands once the comms team and This is the placeholder home page for the APF Portal. Real content lands once the comms team and
the accessibility review panel sign off on the copy. the accessibility review panel sign off on the copy.
</p> </p>
@@ -11,26 +17,40 @@
aria-labelledby="system-status-heading" aria-labelledby="system-status-heading"
class="mt-10 rounded-md border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900" class="mt-10 rounded-md border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
> >
<h2 id="system-status-heading" class="text-base font-medium text-gray-900 dark:text-gray-100"> <h2
id="system-status-heading"
class="text-base font-medium text-gray-900 dark:text-gray-100"
i18n="@@page.home.status.title"
>
System status System status
</h2> </h2>
@if (isLoading()) { @if (isLoading()) {
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400" aria-live="polite"> <p
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
aria-live="polite"
i18n="@@page.home.status.loading"
>
Checking the backend… Checking the backend…
</p> </p>
} @else if (health(); as h) { } @else if (health(); as h) {
<dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm"> <dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500 dark:text-gray-400">Service</dt> <dt class="text-gray-500 dark:text-gray-400" i18n="@@page.home.status.service">Service</dt>
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.service }}</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.service }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Version</dt> <dt class="text-gray-500 dark:text-gray-400" i18n="@@page.home.status.version">Version</dt>
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.version }}</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.version }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Uptime</dt> <dt class="text-gray-500 dark:text-gray-400" i18n="@@page.home.status.uptime">Uptime</dt>
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.uptimeSeconds }} s</dd> <dd class="font-medium text-gray-900 dark:text-gray-100">
<dt class="text-gray-500 dark:text-gray-400">Status</dt> <ng-container i18n="@@page.home.status.uptimeValue">{{ h.uptimeSeconds }} s</ng-container>
</dd>
<dt class="text-gray-500 dark:text-gray-400" i18n="@@page.home.status.status">Status</dt>
<dd class="font-medium text-green-700 dark:text-green-400">{{ h.status }}</dd> <dd class="font-medium text-green-700 dark:text-green-400">{{ h.status }}</dd>
</dl> </dl>
} @else if (isError()) { } @else if (isError()) {
<p class="mt-2 text-sm text-amber-700 dark:text-amber-300" role="status"> <p
class="mt-2 text-sm text-amber-700 dark:text-amber-300"
role="status"
i18n="@@page.home.status.error"
>
Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured. Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured.
</p> </p>
} }
+199 -12
View File
@@ -3,20 +3,207 @@
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template"> <file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body> <body>
<!-- <!--
French translation file for portal-shell. French translations for portal-shell, per ADR-0019.
Empty in this initial plumbing PR per ADR-0019. The first string Source IDs are stable @@-prefixed values authored in the
sweep PR will mark every UI string in the templates with templates / `$localize` calls. Trans-units are grouped by
`i18n="@@<id>"` and add a `<trans-unit id="<id>">` entry below feature (app, footer, header, sidebar, theme, page.*) and
with the French translation. Until then, the production build's alphabetical within each group so diffs stay readable.
`--localize` emits both `dist/apps/portal-shell/browser/en/...`
and `dist/apps/portal-shell/browser/fr/...`, but the FR bundle
falls back to source text for every untranslated key.
Editor convention: keep the entries grouped by feature
(header.*, sidebar.*, footer.*, page.<route>.*) and ordered
alphabetically within each group, so diffs stay readable.
--> -->
<!-- app -->
<trans-unit id="app.skipLink" datatype="html">
<source>Skip to main content</source>
<target>Aller au contenu principal</target>
</trans-unit>
<!-- footer -->
<trans-unit id="footer.aria.label" datatype="html">
<source>Page footer</source>
<target>Pied de page</target>
</trans-unit>
<trans-unit id="footer.aria.legal" datatype="html">
<source>Legal</source>
<target>Mentions légales</target>
</trans-unit>
<trans-unit id="footer.copyright" datatype="html">
<source>© <x id="INTERPOLATION" equiv-text="{{ year }}"/> APF France handicap</source>
<target>© <x id="INTERPOLATION" equiv-text="{{ year }}"/> APF France handicap</target>
</trans-unit>
<!-- header -->
<trans-unit id="header.action.help" datatype="html">
<source>Help</source>
<target>Aide</target>
</trans-unit>
<trans-unit id="header.action.notifications" datatype="html">
<source>Notifications</source>
<target>Notifications</target>
</trans-unit>
<trans-unit id="header.action.settings" datatype="html">
<source>Settings</source>
<target>Paramètres</target>
</trans-unit>
<trans-unit id="header.action.userMenu" datatype="html">
<source>User menu (coming soon)</source>
<target>Menu utilisateur (bientôt disponible)</target>
</trans-unit>
<trans-unit id="header.search.label" datatype="html">
<source>Search the portal</source>
<target>Rechercher dans le portail</target>
</trans-unit>
<trans-unit id="header.search.placeholder" datatype="html">
<source>Search…</source>
<target>Rechercher…</target>
</trans-unit>
<trans-unit id="header.wordmark" datatype="html">
<source>APF Portal</source>
<target>Portail APF</target>
</trans-unit>
<!-- sidebar -->
<trans-unit id="sidebar.aria.label" datatype="html">
<source>Sidebar navigation</source>
<target>Navigation latérale</target>
</trans-unit>
<trans-unit id="sidebar.aria.sections" datatype="html">
<source>Sections</source>
<target>Sections</target>
</trans-unit>
<trans-unit id="sidebar.group.communication" datatype="html">
<source>Communication</source>
<target>Communication</target>
</trans-unit>
<trans-unit id="sidebar.group.establishments" datatype="html">
<source>Establishments</source>
<target>Établissements</target>
</trans-unit>
<trans-unit id="sidebar.group.general" datatype="html">
<source>General</source>
<target>Général</target>
</trans-unit>
<trans-unit id="sidebar.item.activities" datatype="html">
<source>Activities</source>
<target>Activités</target>
</trans-unit>
<trans-unit id="sidebar.item.calendar" datatype="html">
<source>Calendar</source>
<target>Calendrier</target>
</trans-unit>
<trans-unit id="sidebar.item.dashboard" datatype="html">
<source>Dashboard</source>
<target>Tableau de bord</target>
</trans-unit>
<trans-unit id="sidebar.item.directory" datatype="html">
<source>Directory</source>
<target>Annuaire</target>
</trans-unit>
<trans-unit id="sidebar.item.documents" datatype="html">
<source>Documents</source>
<target>Documents</target>
</trans-unit>
<trans-unit id="sidebar.item.messages" datatype="html">
<source>Messages</source>
<target>Messages</target>
</trans-unit>
<trans-unit id="sidebar.role.anonymous" datatype="html">
<source>Anonymous</source>
<target>Anonyme</target>
</trans-unit>
<trans-unit id="sidebar.role.aria" datatype="html">
<source>Current role: anonymous (signed-out)</source>
<target>Rôle actuel : anonyme (non connecté)</target>
</trans-unit>
<trans-unit id="sidebar.role.label" datatype="html">
<source>Role:</source>
<target>Rôle :</target>
</trans-unit>
<trans-unit id="sidebar.toggle.aria.collapse" datatype="html">
<source>Collapse sidebar</source>
<target>Replier la barre latérale</target>
</trans-unit>
<trans-unit id="sidebar.toggle.aria.expand" datatype="html">
<source>Expand sidebar</source>
<target>Déplier la barre latérale</target>
</trans-unit>
<trans-unit id="sidebar.toggle.collapse" datatype="html">
<source>Collapse</source>
<target>Replier</target>
</trans-unit>
<!-- theme -->
<trans-unit id="theme.menu.aria" datatype="html">
<source>Theme</source>
<target>Thème</target>
</trans-unit>
<trans-unit id="theme.mode.dark" datatype="html">
<source>Dark</source>
<target>Sombre</target>
</trans-unit>
<trans-unit id="theme.mode.light" datatype="html">
<source>Light</source>
<target>Clair</target>
</trans-unit>
<trans-unit id="theme.mode.system" datatype="html">
<source>System</source>
<target>Système</target>
</trans-unit>
<trans-unit id="theme.trigger.aria" datatype="html">
<source>Theme: <x id="mode" equiv-text="this.currentLabel()"/> (open menu)</source>
<target>Thème : <x id="mode" equiv-text="this.currentLabel()"/> (ouvrir le menu)</target>
</trans-unit>
<!-- route -->
<trans-unit id="route.accessibility.title" datatype="html">
<source>Accessibility statement · APF Portal</source>
<target>Déclaration daccessibilité · Portail APF</target>
</trans-unit>
<trans-unit id="route.home.title" datatype="html">
<source>APF Portal</source>
<target>Portail APF</target>
</trans-unit>
<!-- page.home -->
<trans-unit id="page.home.intro" datatype="html">
<source> This is the placeholder home page for the APF Portal. Real content lands once the comms team and the accessibility review panel sign off on the copy. </source>
<target> Ceci est la page d'accueil provisoire du portail APF. Le contenu définitif arrivera après validation par l'équipe communication et le panel d'audit d'accessibilité. </target>
</trans-unit>
<trans-unit id="page.home.status.error" datatype="html">
<source> Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured. </source>
<target> Backend inaccessible. Le serveur BFF de développement est peut-être arrêté, ou la configuration CORS est incorrecte. </target>
</trans-unit>
<trans-unit id="page.home.status.loading" datatype="html">
<source> Checking the backend… </source>
<target> Vérification du backend… </target>
</trans-unit>
<trans-unit id="page.home.status.service" datatype="html">
<source>Service</source>
<target>Service</target>
</trans-unit>
<trans-unit id="page.home.status.status" datatype="html">
<source>Status</source>
<target>Statut</target>
</trans-unit>
<trans-unit id="page.home.status.title" datatype="html">
<source> System status </source>
<target> État du système </target>
</trans-unit>
<trans-unit id="page.home.status.uptime" datatype="html">
<source>Uptime</source>
<target>Disponibilité</target>
</trans-unit>
<trans-unit id="page.home.status.uptimeValue" datatype="html">
<source><x id="INTERPOLATION" equiv-text="{{ h.uptimeSeconds }}"/> s</source>
<target><x id="INTERPOLATION" equiv-text="{{ h.uptimeSeconds }}"/> s</target>
</trans-unit>
<trans-unit id="page.home.status.version" datatype="html">
<source>Version</source>
<target>Version</target>
</trans-unit>
<trans-unit id="page.home.title" datatype="html">
<source> Welcome to APF Portal </source>
<target> Bienvenue sur Portail APF </target>
</trans-unit>
</body> </body>
</file> </file>
</xliff> </xliff>
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"types": [] "types": ["@angular/localize"]
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"types": ["vitest/globals"] "types": ["vitest/globals", "@angular/localize"]
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts"] "include": ["src/**/*.ts", "src/**/*.d.ts"]
} }