diff --git a/e2e/seo-metadata.spec.ts b/e2e/seo-metadata.spec.ts
index 3b11a22..46a4431 100644
--- a/e2e/seo-metadata.spec.ts
+++ b/e2e/seo-metadata.spec.ts
@@ -1,4 +1,7 @@
import { expect, test } from "@playwright/test";
+import de from "../messages/de.json";
+import en from "../messages/en.json";
+import fr from "../messages/fr.json";
// Bloc 91/E2–E5: the SEO metadata signals — branded titles, per-page
// descriptions, Open Graph / Twitter cards, the generated OG image and
@@ -225,3 +228,100 @@ test("Bloc 91/F2: an inactive reference still renders but is noindex", async ({
/noindex/,
);
});
+
+// Bloc 95 (audit SEO Bloc 91/F1): the PWA manifest and its icons, so a player
+// can install ML-Helper on a phone's home screen.
+//
+// Served at …/manifest.webmanifest, NOT …/manifest.json: that is the media
+// type's own extension and the route the below points
+// at. Browsers follow that link — they never guess a filename — so the
+// asserted URL is taken from the tag rather than hardcoded, which is also what
+// makes this test notice if the route moves.
+test("Bloc 95: the manifest is linked, served, and describes an installable app", async ({
+ page,
+ request,
+}) => {
+ await page.goto("/fr/tools/villes");
+ const href = await page.locator('link[rel="manifest"]').getAttribute("href");
+ expect(href, "no in the document head").toBeTruthy();
+ expect(href).toContain("/manifest.webmanifest");
+
+ const res = await request.get(href!);
+ expect(res.status()).toBe(200);
+ expect(res.headers()["content-type"]).toContain("manifest+json");
+
+ const manifest = JSON.parse(await res.text());
+ expect(manifest.name).toBe(fr.Public.meta.siteTitle);
+ expect(manifest.short_name).toBe("ML-Helper");
+ // standalone is what drops the browser address bar once installed.
+ expect(manifest.display).toBe("standalone");
+ expect(manifest.start_url).toBe("/");
+ expect(manifest.theme_color).toBe("#8b6bb8");
+ expect(manifest.background_color).toBe("#1b2029");
+ expect(manifest.icons).toHaveLength(2);
+});
+
+// Codex review (PR #120): the name in that manifest is what the install prompt
+// shows, so it follows the language the visitor is reading — each locale links
+// its own manifest instead of all five sharing one French document.
+test("Bloc 95: each locale links a manifest naming the app in its own language", async ({
+ page,
+ request,
+}) => {
+ const names: string[] = [];
+ for (const [locale, messages] of [
+ ["en", en],
+ ["de", de],
+ ] as const) {
+ await page.goto(`/${locale}/tools/villes`);
+ const href = await page
+ .locator('link[rel="manifest"]')
+ .getAttribute("href");
+ expect(href, `no on the ${locale} page`).toBe(
+ `/${locale}/manifest.webmanifest`,
+ );
+
+ const res = await request.get(href!);
+ expect(res.status()).toBe(200);
+ const manifest = JSON.parse(await res.text());
+ expect(manifest.name).toBe(messages.Public.meta.siteTitle);
+ names.push(manifest.name);
+ }
+ // The whole point: three locales, three different names in the prompt.
+ expect(new Set([...names, fr.Public.meta.siteTitle]).size).toBe(3);
+});
+
+test("Bloc 95: both manifest icons are served at the dimensions they declare", async ({
+ request,
+}) => {
+ const res = await request.get("/manifest.webmanifest");
+ const { icons } = JSON.parse(await res.text());
+
+ for (const icon of icons) {
+ const file = await request.get(icon.src);
+ expect(file.status(), `${icon.src} is not served`).toBe(200);
+ expect(file.headers()["content-type"]).toContain("image/png");
+
+ // Width and height straight from the PNG IHDR chunk, so this compares the
+ // bytes actually served against what the manifest promises rather than
+ // trusting the declaration on both sides.
+ const bytes = await file.body();
+ expect(bytes.subarray(1, 4).toString("ascii")).toBe("PNG");
+ const width = bytes.readUInt32BE(16);
+ const height = bytes.readUInt32BE(20);
+ expect(`${width}x${height}`, `${icon.src} has the wrong size`).toBe(
+ icon.sizes,
+ );
+ }
+});
+
+test("Bloc 95: the Apple touch icon is exposed for iOS home screens", async ({
+ page,
+}) => {
+ await page.goto("/fr/tools/villes");
+ // iOS ignores the manifest icons for the home screen and uses this tag,
+ // which the src/app/apple-icon.png file convention emits.
+ const appleIcon = page.locator('link[rel="apple-touch-icon"]');
+ await expect(appleIcon).toHaveCount(1);
+ expect(await appleIcon.getAttribute("href")).toContain("/apple-icon.png");
+});
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index d2df4d1..44e0e99 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -1,3 +1,4 @@
+import type { Metadata } from "next";
import type { ReactNode } from "react";
import { headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
@@ -13,6 +14,19 @@ export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
+// Bloc 95, Codex review (PR #120): point this language's pages at this
+// language's manifest, so the name Chrome shows in the install prompt is the
+// one the visitor is reading. Without this every locale would inherit the root
+// file convention's single /manifest.webmanifest, which can only carry one
+// language. Only `manifest` is set here — every other metadata field still
+// comes from the root layout and each page's own generateMetadata.
+export async function generateMetadata({
+ params,
+}: LayoutProps<"/[locale]">): Promise {
+ const { locale } = await params;
+ return { manifest: `/${locale}/manifest.webmanifest` };
+}
+
export default async function LocaleLayout({
children,
params,
diff --git a/src/app/[locale]/manifest.webmanifest/route.ts b/src/app/[locale]/manifest.webmanifest/route.ts
new file mode 100644
index 0000000..befba11
--- /dev/null
+++ b/src/app/[locale]/manifest.webmanifest/route.ts
@@ -0,0 +1,42 @@
+import { NextResponse } from "next/server";
+import { notFound } from "next/navigation";
+import { hasLocale } from "next-intl";
+import { getTranslations } from "next-intl/server";
+import { routing } from "@/i18n/routing";
+import { buildWebManifest } from "@/lib/web-manifest";
+
+// Bloc 95, Codex review (PR #120): the manifest of the language the visitor is
+// actually reading. The `app/manifest` file convention only exists at the app
+// root and produces a single static document, so a translated name needs a
+// route of its own under /[locale]/ — /fr/manifest.webmanifest,
+// /en/manifest.webmanifest, … Each locale's public pages point at theirs (see
+// generateMetadata in src/app/[locale]/layout.tsx), so the name Android and
+// Chrome show in the install prompt is in the visitor's language rather than
+// the one frozen into a shared file.
+//
+// Everything except the name is identical across locales and comes from
+// buildWebManifest, so the two entry points can't drift apart.
+
+// Prerenders the 5 manifests at build time, like the locale layout does for
+// the pages themselves.
+export function generateStaticParams() {
+ return routing.locales.map((locale) => ({ locale }));
+}
+
+export async function GET(
+ _request: Request,
+ { params }: RouteContext<"/[locale]/manifest.webmanifest">,
+) {
+ const { locale } = await params;
+ // Unknown segment (not one of the 5 launched locales) → 404, same as the
+ // locale layout: a manifest for a language that doesn't exist would name the
+ // app in whatever the fallback happened to be.
+ if (!hasLocale(routing.locales, locale)) notFound();
+
+ const t = await getTranslations({ locale, namespace: "Public.meta" });
+ return NextResponse.json(buildWebManifest(t("siteTitle")), {
+ // The manifest's own media type; browsers accept application/json too, but
+ // this is what the spec asks for and what the root file convention emits.
+ headers: { "content-type": "application/manifest+json" },
+ });
+}
diff --git a/src/app/apple-icon.png b/src/app/apple-icon.png
new file mode 100644
index 0000000..96ebc04
Binary files /dev/null and b/src/app/apple-icon.png differ
diff --git a/src/app/icon.png b/src/app/icon.png
new file mode 100644
index 0000000..004a616
Binary files /dev/null and b/src/app/icon.png differ
diff --git a/src/app/manifest.test.ts b/src/app/manifest.test.ts
new file mode 100644
index 0000000..fd14311
--- /dev/null
+++ b/src/app/manifest.test.ts
@@ -0,0 +1,190 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { describe, expect, it, vi } from "vitest";
+
+// The translator the manifest routes use, backed by the real messages/*.json
+// files (through the same English-fallback merge the app uses at runtime), so
+// these tests compare the served name against the actual translations rather
+// than against a stub — a manifest that stopped following next-intl would show
+// up here.
+vi.mock("next-intl/server", () => ({
+ getTranslations: async ({
+ locale,
+ namespace,
+ }: {
+ locale: string;
+ namespace: string;
+ }) => {
+ const { getMessagesForLocale } = await import("@/i18n/config");
+ const messages = await getMessagesForLocale(locale);
+ return (key: string) =>
+ [...namespace.split("."), key].reduce(
+ (node, part) => (node as Record)?.[part],
+ messages,
+ ) as string;
+ },
+}));
+
+const { default: manifest } = await import("./manifest");
+const { GET, generateStaticParams } =
+ await import("./[locale]/manifest.webmanifest/route");
+
+const globalsCss = readFileSync(path.join(__dirname, "globals.css"), "utf8");
+
+/** The site name as it is really written in one locale's message file. */
+function siteTitle(locale: string): string {
+ const messages = JSON.parse(
+ readFileSync(
+ path.join(process.cwd(), "messages", `${locale}.json`),
+ "utf8",
+ ),
+ );
+ return messages.Public.meta.siteTitle;
+}
+
+/** Fetches one locale's manifest through its route handler. */
+async function localeManifest(locale: string) {
+ const response = await GET(
+ new Request(`http://x/${locale}/manifest.webmanifest`),
+ {
+ params: Promise.resolve({ locale }),
+ },
+ );
+ return { response, data: await response.json() };
+}
+
+// The dark theme's token block — the default the site renders in, and so the
+// palette an installed app's splash screen and browser chrome should match.
+const darkBlock = globalsCss.slice(
+ globalsCss.indexOf(":root,"),
+ globalsCss.indexOf(':root[data-theme="light"]'),
+);
+
+/** Resolves a token to a hex, following one level of `var(--other)` aliasing. */
+function token(name: string): string {
+ const raw = new RegExp(`--${name}:\\s*([^;]+);`).exec(darkBlock);
+ if (!raw) throw new Error(`--${name} not found in the dark theme block`);
+ const value = raw[1].trim();
+ const alias = /^var\(--([\w-]+)\)$/.exec(value);
+ return alias ? token(alias[1]) : value;
+}
+
+/** Width and height straight from the PNG IHDR chunk (bytes 16-24). */
+function pngSize(file: string): { width: number; height: number } {
+ const buffer = readFileSync(path.join(__dirname, file));
+ expect(buffer.subarray(1, 4).toString("ascii")).toBe("PNG");
+ return {
+ width: buffer.readUInt32BE(16),
+ height: buffer.readUInt32BE(20),
+ };
+}
+
+// Bloc 95 (audit SEO Bloc 91/F1): the manifest that makes ML-Helper
+// installable on a phone's home screen.
+const data = await manifest();
+
+describe("web app manifest", () => {
+ it("names the app both in full and in the short form shown under the icon", async () => {
+ // Codex review (PR #120): the full name is user-visible text, so it comes
+ // from next-intl like everything else — this root document is the one the
+ // non-prefixed routes (/admin, /login) get, in the fallback language.
+ expect(data.name).toBe(siteTitle("en"));
+ expect(data.short_name).toBe("ML-Helper");
+ // The short name is what a home screen actually has room for.
+ expect(data.short_name!.length).toBeLessThanOrEqual(12);
+ });
+
+ it("opens as an app, from the site root", () => {
+ expect(data.display).toBe("standalone");
+ // "/" and not "/fr": src/proxy.ts redirects the bare root to the visitor's
+ // own language, so the installed app is not frozen to one locale.
+ expect(data.start_url).toBe("/");
+ expect(data.id).toBe("/");
+ });
+
+ // Guards the reason these two values exist: they are the site's own tokens.
+ // A palette change that left this file behind would show a mismatched splash
+ // screen before the first paint, which is exactly what nobody would notice.
+ it("takes its colours from the site's dark theme, not from new values", () => {
+ expect(data.background_color).toBe(token("bg"));
+ expect(data.theme_color).toBe(token("accent"));
+ });
+
+ it("lists both icons with the size and MIME type each file really has", () => {
+ const icons = data.icons ?? [];
+ expect(icons).toHaveLength(2);
+
+ for (const icon of icons) {
+ expect(icon.type).toBe("image/png");
+ // The declared size must match the file on disk: a manifest that lies
+ // about its icons gets them rejected or rendered blurry.
+ const file = icon.src!.replace(/^\//, "");
+ const { width, height } = pngSize(file);
+ expect(`${width}x${height}`).toBe(icon.sizes);
+ expect(width).toBe(height);
+ }
+
+ expect(icons.map((icon) => icon.src)).toEqual([
+ "/icon.png",
+ "/apple-icon.png",
+ ]);
+ expect(icons.map((icon) => icon.sizes)).toEqual(["192x192", "512x512"]);
+ });
+
+ it("does not claim maskable, which would clip the shield", () => {
+ // The artwork spans ~83% of the square, wider than the 80% safe zone a
+ // maskable icon is cropped to. Declaring it would cut the shield's edges.
+ for (const icon of data.icons ?? [])
+ expect(icon.purpose ?? "any").toBe("any");
+ });
+});
+
+// Codex review (PR #120): a single manifest can only name the app in one
+// language, and the name is what the install prompt shows. Each locale serves
+// its own.
+describe("per-locale manifest route", () => {
+ const locales = ["fr", "en", "de", "es", "tr"];
+
+ it("prerenders one manifest per launched locale", () => {
+ expect(generateStaticParams().map((entry) => entry.locale)).toEqual(
+ expect.arrayContaining(locales),
+ );
+ });
+
+ it.each(locales)(
+ "names the app in %s, with the manifest media type",
+ async (locale) => {
+ const { response, data } = await localeManifest(locale);
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toContain("manifest+json");
+ expect(data.name).toBe(siteTitle(locale));
+ },
+ );
+
+ it("really does serve five different names", async () => {
+ const names = await Promise.all(
+ locales.map(async (locale) => (await localeManifest(locale)).data.name),
+ );
+ // The point of the route: one hardcoded name for all 5 would pass every
+ // assertion above that only checks shape.
+ expect(new Set(names).size).toBe(locales.length);
+ });
+
+ it("changes nothing but the name", async () => {
+ const { data: fr } = await localeManifest("fr");
+ const root = await manifest();
+ expect({ ...fr, name: null }).toEqual({ ...root, name: null });
+ });
+
+ it("404s on a locale the site does not have", async () => {
+ // notFound() throws Next's NEXT_HTTP_ERROR_FALLBACK;404 signal.
+ await expect(localeManifest("it")).rejects.toThrow();
+ });
+});
+
+describe("installable icon files", () => {
+ it("ships the two Next.js file-convention icons at their intended sizes", () => {
+ expect(pngSize("icon.png")).toEqual({ width: 192, height: 192 });
+ expect(pngSize("apple-icon.png")).toEqual({ width: 512, height: 512 });
+ });
+});
diff --git a/src/app/manifest.ts b/src/app/manifest.ts
new file mode 100644
index 0000000..7b2f84d
--- /dev/null
+++ b/src/app/manifest.ts
@@ -0,0 +1,24 @@
+import type { MetadataRoute } from "next";
+import { getTranslations } from "next-intl/server";
+import { fallbackLocale } from "@/i18n/config";
+import { buildWebManifest } from "@/lib/web-manifest";
+
+// Bloc 95 (audit SEO Bloc 91/F1): the web app manifest, so players can install
+// ML-Helper on a phone's home screen and open it without browser chrome.
+//
+// Codex review (PR #120): the installed app's name is text a user reads, so it
+// goes through next-intl like every other visible string (AGENTS.md) — it
+// reuses Public.meta.siteTitle, the site's own name, already translated in the
+// 5 locales. A single file-convention manifest is one static document and
+// cannot vary per visitor, so this one carries the English wording (the site's
+// documented fallback) and covers the routes that are not locale-prefixed
+// (/admin, /login). Every public page links to its own language's manifest
+// instead — src/app/[locale]/manifest.webmanifest/route.ts, wired up by the
+// locale layout's generateMetadata.
+export default async function manifest(): Promise {
+ const t = await getTranslations({
+ locale: fallbackLocale,
+ namespace: "Public.meta",
+ });
+ return buildWebManifest(t("siteTitle"));
+}
diff --git a/src/lib/web-manifest.ts b/src/lib/web-manifest.ts
new file mode 100644
index 0000000..3d9bd71
--- /dev/null
+++ b/src/lib/web-manifest.ts
@@ -0,0 +1,46 @@
+import type { MetadataRoute } from "next";
+
+// Bloc 95 (audit SEO Bloc 91/F1): everything in the web app manifest that is
+// the same in all 5 languages. Only the name is translated, so it is the one
+// thing this builder takes as an argument — see src/app/manifest.ts (the
+// English fallback served at /manifest.webmanifest) and
+// src/app/[locale]/manifest.webmanifest/route.ts (the per-locale one every
+// public page actually links to).
+//
+// Colours are the site's own tokens, not new values: --bg (#1b2029) is the
+// dark theme's page background, so the splash screen matches what renders
+// right after it; --accent (#8b6bb8, the violet) tints the browser UI. Both
+// are asserted against globals.css by manifest.test.ts, so a palette change
+// can't silently leave this file behind.
+const themeColor = "#8b6bb8";
+const backgroundColor = "#1b2029";
+
+export function buildWebManifest(name: string): MetadataRoute.Manifest {
+ return {
+ id: "/",
+ // start_url is "/" rather than a locale-prefixed path: src/proxy.ts
+ // redirects the bare root to the visitor's own language, so an installed
+ // app opens in the locale they actually use instead of one frozen at
+ // install time.
+ start_url: "/",
+ name,
+ // The short name (what fits under a home screen icon) is the brand alone,
+ // identical in every language — nothing to translate.
+ short_name: "ML-Helper",
+ display: "standalone",
+ theme_color: themeColor,
+ background_color: backgroundColor,
+ icons: [
+ // Both files are Next.js file-convention icons (src/app/icon.png and
+ // src/app/apple-icon.png), which is what also emits the
+ // and tags. Listing them here is what makes
+ // them available to the install prompt itself.
+ //
+ // purpose is left at the default ("any") deliberately: the shield spans
+ // ~83% of the square, wider than a maskable icon's 80% safe zone, so
+ // declaring "maskable" would have Android clip its edges.
+ { src: "/icon.png", sizes: "192x192", type: "image/png" },
+ { src: "/apple-icon.png", sizes: "512x512", type: "image/png" },
+ ],
+ };
+}