feat(portal-shell): i18n string sweep — mark UI strings + FR translations
CI / commits (pull_request) Successful in 2m12s
CI / scan (pull_request) Successful in 2m13s
CI / check (pull_request) Successful in 2m22s
CI / a11y (pull_request) Successful in 2m13s
CI / perf (pull_request) Successful in 5m27s

Continue ADR-0019 implementation. Mark every UI string surfaced by
the shell with `i18n="@@id"` (templates) or `$localize` (TypeScript)
and populate `messages.fr.xlf` with French translations. 43 trans-
units total: skip-link, header, sidebar, theme switcher, footer,
home page, route titles.

What's marked:

- `app.html` skip-link.
- `header.html` brand wordmark, search label + placeholder, action
  buttons (notifications / help / settings), user-menu placeholder.
- `sidebar.ts` MENU data (group + item labels) and a computed aria-
  label for the toggle button (Expand / Collapse).
- `sidebar.html` aside / sections / role / collapse strings.
- `theme-switcher.ts` MODE_OPTIONS labels + computed trigger aria
  ("Theme: <mode> (open menu)" with a named placeholder).
- `theme-switcher.html` menu aria-label.
- `footer.html` aria-labels + copyright. The two accessibility-page
  link labels stay bilingual on purpose (one per language, each
  carrying its `lang` attribute) — the dual-link pattern goes away
  in the upcoming route-fusion PR.
- `home.html` welcome heading + intro + status widget labels.
- `app.routes.ts` browser tab titles. The `/accessibility` and
  `/accessibilite` routes share one title key; the route-fusion PR
  will collapse them to a single localised route.

Tooling:

- Add `"@angular/localize"` to the `types` array in both
  `tsconfig.app.json` and `tsconfig.spec.json` so TypeScript
  resolves the `$localize` global.

Translation choices worth flagging:

- Wordmark: "APF Portal" → "Portail APF" (consistent with the route
  title key).
- "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).

Verification:

- Production build emits both locales (`dist/.../{en,fr}/`).
  Spot-checked the FR bundle: no leftover English source text on
  any of the swept strings, route titles, or the home page intro.
- Initial bundle: 129 kB gzip per locale (vs 122 kB before).
  +7 kB gzip absorbs the i18n marker metadata + the FR translation
  data embedded in the FR bundle. Well under the 300 KB budget.
- 36 / 36 specs unchanged (they run in the source locale, so the
  English assertions still match).

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.
- CI gate that fails the build on a missing translation.
This commit is contained in:
Julien Gautier
2026-05-11 19:46:13 +02:00
parent 04675b1b59
commit 47277114da
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"]
} }