diff --git a/docs/patterns/06-friendly-errors.md b/docs/patterns/06-friendly-errors.md
index 06e731d..d736963 100644
--- a/docs/patterns/06-friendly-errors.md
+++ b/docs/patterns/06-friendly-errors.md
@@ -2,7 +2,7 @@
> Backported from adaptive-learner. Template-neutral; adapt the names to your app.
-**Status in this template:** the backend exception hierarchy (`MyAppError` and its subclasses) and the global exception handler are present in `backend/app/exceptions.py` and `backend/app/main.py`; the frontend `ApiError` class is present in `frontend/src/api/client.ts`. The `ui.errors.*` i18n mapping, the `friendlyError` helper, and the Dev-Mode toggle are not yet implemented.
+**Status in this template:** **shipped.** On top of the backend exception hierarchy (`MyAppError` + global handler) and the frontend `ApiError` class, the friendly layer now ships: `utils/friendlyError.ts` maps an `ApiError` by status class to a calm message (resolved via the `ui.errors.*` keys with English fallbacks, so it works before an app adds catalog entries); `utils/devMode.ts` + a Settings > About toggle gate the raw technical detail (off by default); and `notify.error` shows the friendly message while the "Report issue" action still carries the full raw detail + stacktrace. A non-hook `translate()` accessor on `hooks/useI18n.ts` lets module-level code localize without a hook.
## Why
diff --git a/frontend/src/components/about/About.module.css b/frontend/src/components/about/About.module.css
index 11494a7..2153f36 100644
--- a/frontend/src/components/about/About.module.css
+++ b/frontend/src/components/about/About.module.css
@@ -67,3 +67,12 @@
font-size: 0.875rem;
line-height: 1.5;
}
+
+.toggleRow {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ cursor: pointer;
+}
diff --git a/frontend/src/components/about/AboutTab.tsx b/frontend/src/components/about/AboutTab.tsx
index 85edd9b..6743c72 100644
--- a/frontend/src/components/about/AboutTab.tsx
+++ b/frontend/src/components/about/AboutTab.tsx
@@ -9,6 +9,7 @@ import {BookOpen, Code2, ExternalLink, Scale} from "lucide-react";
import {useI18n} from "../../hooks/useI18n";
import VersionSection from "./VersionSection";
import ShareAppSection from "./ShareAppSection";
+import DevModeSection from "./DevModeSection";
import UpdateCheckControl from "../UpdateCheckControl";
import styles from "./About.module.css";
@@ -97,6 +98,8 @@ export default function AboutTab() {
)}
+
+
);
}
diff --git a/frontend/src/components/about/DevModeSection.tsx b/frontend/src/components/about/DevModeSection.tsx
new file mode 100644
index 0000000..2caa654
--- /dev/null
+++ b/frontend/src/components/about/DevModeSection.tsx
@@ -0,0 +1,39 @@
+/**
+ * Developer Mode toggle for the About tab (docs/patterns/06-friendly-errors.md).
+ *
+ * Off by default. When on, error toasts show the raw technical detail
+ * (status, endpoint, stacktrace) instead of the friendly user message.
+ */
+import {useId, useState} from "react";
+import {useI18n} from "../../hooks/useI18n";
+import {isDevMode, setDevMode} from "../../utils/devMode";
+import styles from "./About.module.css";
+
+export default function DevModeSection() {
+ const {t} = useI18n();
+ const [on, setOn] = useState(isDevMode());
+ const id = useId();
+
+ const toggle = (next: boolean) => {
+ setDevMode(next);
+ setOn(next);
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/hooks/useI18n.ts b/frontend/src/hooks/useI18n.ts
index d570795..bfbc31e 100644
--- a/frontend/src/hooks/useI18n.ts
+++ b/frontend/src/hooks/useI18n.ts
@@ -34,6 +34,24 @@ const I18nContext = createContext(null);
let cachedLang = DEFAULT_LANG;
let cachedStrings: I18nStrings = deCatalog as I18nStrings;
+/**
+ * Non-hook translation accessor for module-level code (e.g. utils/notify,
+ * utils/friendlyError) that cannot call the `useI18n` hook. Resolves against
+ * the catalog the provider has loaded; falls back to `fallback` or the key.
+ */
+export function translate(key: string, fallback?: string): string {
+ const parts = key.split(".");
+ let current: unknown = cachedStrings;
+ for (const part of parts) {
+ if (current && typeof current === "object" && part in (current as Record)) {
+ current = (current as Record)[part];
+ } else {
+ return fallback ?? key;
+ }
+ }
+ return typeof current === "string" ? current : (fallback ?? key);
+}
+
export function I18nProvider({children}: {children: ReactNode}) {
const [strings, setStrings] = useState(cachedStrings);
const [lang, setLangState] = useState(cachedLang || "de");
diff --git a/frontend/src/utils/devMode.test.ts b/frontend/src/utils/devMode.test.ts
new file mode 100644
index 0000000..e04373b
--- /dev/null
+++ b/frontend/src/utils/devMode.test.ts
@@ -0,0 +1,20 @@
+import {afterEach, describe, expect, it} from "vitest";
+import {isDevMode, setDevMode} from "./devMode";
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("devMode", () => {
+ it("is off by default", () => {
+ localStorage.clear();
+ expect(isDevMode()).toBe(false);
+ });
+
+ it("persists on and off", () => {
+ setDevMode(true);
+ expect(isDevMode()).toBe(true);
+ setDevMode(false);
+ expect(isDevMode()).toBe(false);
+ });
+});
diff --git a/frontend/src/utils/devMode.ts b/frontend/src/utils/devMode.ts
new file mode 100644
index 0000000..d0d2688
--- /dev/null
+++ b/frontend/src/utils/devMode.ts
@@ -0,0 +1,25 @@
+/**
+ * Developer Mode (docs/patterns/06-friendly-errors.md).
+ *
+ * Off by default. When ON, error toasts show the raw technical detail
+ * (status, endpoint, stacktrace) instead of the friendly user message - for
+ * debugging. Persisted in localStorage so it survives reloads. Toggle it
+ * from Settings > About.
+ */
+const KEY = "myapp.devMode";
+
+export function isDevMode(): boolean {
+ try {
+ return localStorage.getItem(KEY) === "true";
+ } catch {
+ return false;
+ }
+}
+
+export function setDevMode(on: boolean): void {
+ try {
+ localStorage.setItem(KEY, on ? "true" : "false");
+ } catch {
+ /* storage unavailable (private mode); dev mode just stays off */
+ }
+}
diff --git a/frontend/src/utils/friendlyError.test.ts b/frontend/src/utils/friendlyError.test.ts
new file mode 100644
index 0000000..f86d66f
--- /dev/null
+++ b/frontend/src/utils/friendlyError.test.ts
@@ -0,0 +1,38 @@
+import {afterEach, describe, expect, it, vi} from "vitest";
+import {ApiError} from "../api/client";
+
+vi.mock("./devMode", () => ({isDevMode: vi.fn(() => false)}));
+vi.mock("../hooks/useI18n", () => ({translate: (_key: string, fallback: string) => fallback}));
+
+import {friendlyMessage} from "./friendlyError";
+import {isDevMode} from "./devMode";
+
+const apiErr = (status: number) => new ApiError(status, "raw backend detail", "/api/x", "GET");
+
+afterEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(isDevMode).mockReturnValue(false);
+});
+
+describe("friendlyMessage", () => {
+ it("maps status classes to friendly text in production mode", () => {
+ expect(friendlyMessage("raw", apiErr(404)).toLowerCase()).toContain("find");
+ expect(friendlyMessage("raw", apiErr(422)).toLowerCase()).toContain("information");
+ expect(friendlyMessage("raw", apiErr(409)).toLowerCase()).toContain("conflict");
+ expect(friendlyMessage("raw", apiErr(500)).toLowerCase()).toContain("our side");
+ expect(friendlyMessage("raw", apiErr(0)).toLowerCase()).toContain("server");
+ });
+
+ it("never returns the raw backend detail for an ApiError in production", () => {
+ expect(friendlyMessage("raw backend detail", apiErr(500))).not.toContain("raw backend detail");
+ });
+
+ it("returns the raw message for a non-ApiError", () => {
+ expect(friendlyMessage("plain message")).toBe("plain message");
+ });
+
+ it("returns the raw detail in Developer Mode", () => {
+ vi.mocked(isDevMode).mockReturnValue(true);
+ expect(friendlyMessage("raw backend detail", apiErr(500))).toBe("raw backend detail");
+ });
+});
diff --git a/frontend/src/utils/friendlyError.ts b/frontend/src/utils/friendlyError.ts
new file mode 100644
index 0000000..e873edd
--- /dev/null
+++ b/frontend/src/utils/friendlyError.ts
@@ -0,0 +1,43 @@
+/**
+ * Friendly error messages (docs/patterns/06-friendly-errors.md).
+ *
+ * Production users should never see a raw HTTP `detail` or a stacktrace in a
+ * toast. This maps an {@link ApiError} to a calm, localized message by status
+ * class. Developer Mode (utils/devMode) bypasses the mapping and returns the
+ * raw detail so developers still get the actionable text. The "Report issue"
+ * action always carries the full technical detail regardless of mode.
+ *
+ * i18n: messages resolve via the non-hook `translate()` accessor against the
+ * `ui.errors.*` keys; the English fallbacks here are used until an app adds
+ * those keys to its catalogs (so the feature works with zero i18n setup).
+ */
+import {ApiError} from "../api/client";
+import {translate} from "../hooks/useI18n";
+import {isDevMode} from "./devMode";
+
+function keyForStatus(status: number): {key: string; fallback: string} {
+ if (status === 404) return {key: "ui.errors.not_found", fallback: "We couldn't find what you were looking for."};
+ if (status === 400 || status === 422) return {key: "ui.errors.validation", fallback: "Some of the information looks off. Please check and try again."};
+ if (status === 409) return {key: "ui.errors.conflict", fallback: "That action conflicts with the current state. Please reload and retry."};
+ if (status === 413) return {key: "ui.errors.too_large", fallback: "That file or request is too large."};
+ if (status === 0) return {key: "ui.errors.network", fallback: "Can't reach the server. Check your connection and try again."};
+ if (status >= 500) return {key: "ui.errors.server", fallback: "Something went wrong on our side. Please try again."};
+ return {key: "ui.errors.unexpected", fallback: "Something unexpected happened. Please try again."};
+}
+
+/**
+ * Resolve the message to SHOW the user for a failed action.
+ *
+ * @param rawMessage the message the caller would have shown (usually
+ * `error.detail`); returned as-is in Developer Mode or for non-ApiErrors.
+ * @param error the caught error; when it's an ApiError and Developer Mode is
+ * off, a friendly status-mapped message is returned instead.
+ */
+export function friendlyMessage(rawMessage: string, error?: unknown): string {
+ if (isDevMode()) return rawMessage;
+ if (error instanceof ApiError) {
+ const {key, fallback} = keyForStatus(error.status);
+ return translate(key, fallback);
+ }
+ return rawMessage;
+}
diff --git a/frontend/src/utils/notify.ts b/frontend/src/utils/notify.ts
index 4810c32..e818940 100644
--- a/frontend/src/utils/notify.ts
+++ b/frontend/src/utils/notify.ts
@@ -14,6 +14,7 @@
import React from 'react'
import {toast} from 'react-toastify'
import {ApiError} from '../api/client'
+import {friendlyMessage} from './friendlyError'
// Truncate the visible error message so the toast stays readable.
// The full detail is still embedded in the ErrorReportDialog body.
@@ -28,7 +29,7 @@ function truncateForDisplay(message: string): string {
return message.slice(0, MAX_DISPLAY_LENGTH) + '...'
}
-function ErrorContent({message, apiError}: {message: string; apiError?: ApiError}) {
+function ErrorContent({message, rawMessage, apiError}: {message: string; rawMessage?: string; apiError?: ApiError}) {
return React.createElement(
'div',
{
@@ -63,7 +64,7 @@ function ErrorContent({message, apiError}: {message: string; apiError?: ApiError
e.stopPropagation()
// Dispatch a custom event that ErrorReportDialog listens for
window.dispatchEvent(new CustomEvent('myapp:open-error-report', {
- detail: {message, apiError},
+ detail: {message: rawMessage ?? message, apiError},
}))
},
style: {
@@ -217,7 +218,10 @@ export const notify = {
error: (message: string, apiError?: unknown) => {
recordToast('error', message)
const err = apiError instanceof ApiError ? apiError : undefined
- return toast.error(React.createElement(ErrorContent, {message, apiError: err}), {
+ // Friendly message for users (raw detail only in Developer Mode); the
+ // raw message + apiError still flow to the "Report issue" dialog.
+ const display = friendlyMessage(message, apiError)
+ return toast.error(React.createElement(ErrorContent, {message: display, rawMessage: message, apiError: err}), {
autoClose: 15000,
closeOnClick: false,
})