diff --git a/frontend/e2e/fleet-ops-correction.spec.ts b/frontend/e2e/fleet-ops-correction.spec.ts
index a24ea23..2325394 100644
--- a/frontend/e2e/fleet-ops-correction.spec.ts
+++ b/frontend/e2e/fleet-ops-correction.spec.ts
@@ -295,3 +295,58 @@ test("automation shows a localized error explanation with the raw error only und
await page.getByText("Technische details").first().click();
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
});
+
+test.describe("route matrix (section 11F)", () => {
+ // Opens every main route in all 3 languages: no console errors, correct html[lang],
+ // and a real, non-empty page heading (proving the route actually rendered content
+ // instead of silently falling back to a raw i18next key or a blank screen). Key
+ // parity across locale files is already proven structurally by i18n-coverage.spec.ts
+ // (every key that exists in nl-BE also exists, non-empty, in en-GB/fr-BE), so this
+ // matrix focuses on what only a live render can catch.
+ const routes = [
+ "/dashboard",
+ "/vehicles",
+ "/vehicles/MO-001",
+ "/bookings",
+ "/bookings/BK-DEMO-RETURN",
+ "/data-quality",
+ "/data-quality/DQ-DEMO-STATUS",
+ "/automation",
+ "/knowledge",
+ "/audit",
+ "/scenarios",
+ "/about",
+ ];
+
+ for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
+ test(`every main route renders correctly with no console errors (${lang})`, async ({ page, request }) => {
+ await resetDemoData(request);
+
+ const errors: string[] = [];
+ page.on("console", (msg) => {
+ if (msg.type() !== "error") return;
+ if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
+ errors.push(msg.text());
+ });
+ page.on("pageerror", (err) => errors.push(err.message));
+
+ await loginAsOpsManager(page, lang);
+
+ for (const route of routes) {
+ await page.goto(route);
+ await expect(page.locator("html")).toHaveAttribute("lang", lang);
+ const heading = page.getByRole("heading", { level: 1 });
+ await expect(heading, `${route} (${lang})`).toBeVisible();
+ const headingText = (await heading.first().textContent())?.trim() ?? "";
+ expect(headingText, `${route} (${lang}) heading text`).not.toBe("");
+ // A raw, unresolved i18next key looks like "namespace:some.key.path" -- real
+ // page headings never contain a colon followed by a dotted identifier.
+ expect(headingText, `${route} (${lang}) heading looks like a raw i18n key`).not.toMatch(
+ /^[a-zA-Z]+:[\w.]+$/,
+ );
+ }
+
+ expect(errors, `Console errors across the route matrix (${lang}):\n${errors.join("\n")}`).toEqual([]);
+ });
+ }
+});
diff --git a/frontend/e2e/i18n-coverage.spec.ts b/frontend/e2e/i18n-coverage.spec.ts
index e178b58..c83e01c 100644
--- a/frontend/e2e/i18n-coverage.spec.ts
+++ b/frontend/e2e/i18n-coverage.spec.ts
@@ -184,3 +184,74 @@ test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or ea
}
}
});
+
+// --- Hardcoded JSX text (section 11D) ---
+// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
+// `{...}` expression) containing two or more real words are almost always user-facing
+// prose that should go through t(...). This is not a full parser, so a short, explicit
+// allowlist covers technical tokens/proper nouns that are correctly never translated
+// (MobilityOps.md is checked for absence elsewhere; this list is for things that ARE
+// expected to appear literally in JSX).
+const SRC_DIR = path.resolve(__dirname, "../src");
+const SCAN_DIRS = ["pages", "components"];
+
+const ALLOWED_LITERAL_TEXT = new Set([
+ "Fleet Ops", // the non-localizable brand name (frontend/src/product.ts)
+ "Northstar Mobility", // fictional demo org, a proper noun
+ "ITWorx MCP Hub", // proper noun
+]);
+
+function collectTsxFiles(dir: string): string[] {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ return entries.flatMap((entry) => {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) return collectTsxFiles(full);
+ return entry.name.endsWith(".tsx") ? [full] : [];
+ });
+}
+
+function findHardcodedJsxText(filePath: string): string[] {
+ const source = fs.readFileSync(filePath, "utf-8");
+ const findings: string[] = [];
+ // Matches `text` where the closing tag name backreferences the
+ // opening one -- this specifically excludes TypeScript generics like
+ // `useState(null)`, which have no matching `` closer,
+ // unlike a naive `>...<` scan would. Deliberately spans newlines (Prettier commonly
+ // puts JSX text on its own line) and does not attempt to parse JSX properly -- it is
+ // a fast, approximate net for the common mistake, not a compiler.
+ const jsxTextPattern = /<([A-Za-z][\w.]*)(?:\s[^<>]*)?>([^<>{}]{3,200})<\/\1>/gs;
+ let match: RegExpExecArray | null;
+ while ((match = jsxTextPattern.exec(source)) !== null) {
+ const text = match[2].trim();
+ if (!text) continue;
+ if (ALLOWED_LITERAL_TEXT.has(text)) continue;
+ // Needs at least two alphabetic words to count as "prose" -- filters out numbers,
+ // single technical words, units (km, %), punctuation-only fragments, and JSX
+ // whitespace artifacts.
+ const words = text.match(/[A-Za-z]+/g) ?? [];
+ if (words.length < 2) continue;
+ // Skip anything that is itself an i18next interpolation artifact leaking through
+ // (shouldn't happen, but never flag `{{...}}`-shaped remnants) or looks like a URL
+ // or path.
+ if (/^https?:\/\//.test(text) || text.includes("/") || text.includes("{{")) continue;
+ findings.push(`${path.relative(SRC_DIR, filePath)}: "${text}"`);
+ }
+ return findings;
+}
+
+test("no hardcoded user-facing JSX text outside the approved technical-token allowlist", () => {
+ const allFindings: string[] = [];
+ for (const dir of SCAN_DIRS) {
+ const files = collectTsxFiles(path.join(SRC_DIR, dir));
+ for (const file of files) {
+ allFindings.push(...findHardcodedJsxText(file));
+ }
+ }
+ expect(
+ allFindings,
+ `Found ${allFindings.length} likely hardcoded JSX string(s) bypassing t(...). ` +
+ `Either route it through the translation system, or add the exact literal to ` +
+ `ALLOWED_LITERAL_TEXT in this test if it's a genuine proper noun/technical token:\n` +
+ allFindings.join("\n"),
+ ).toEqual([]);
+});