diff --git a/messages/de.json b/messages/de.json
index 9a3d0cb6c..6256bc25b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5355,6 +5355,14 @@
"profileSaved": "Profil gespeichert",
"profilePartiallySaved": "Profil gespeichert, aber {field} konnte nicht aktualisiert werden.",
"profileNothingSaved": "Es wurde nichts gespeichert. Bitte {field} korrigieren und erneut versuchen.",
+ "profileRejection": {
+ "tooBig": "Der Wert liegt über dem Bereich, den dieses Feld annimmt.",
+ "tooSmall": "Der Wert liegt unter dem Bereich, den dieses Feld annimmt.",
+ "invalidValue": "Das ist keine der Antworten, die dieses Feld annimmt.",
+ "invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.",
+ "invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.",
+ "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen."
+ },
"avatar": {
"title": "Profilfoto",
"description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.",
diff --git a/messages/en.json b/messages/en.json
index ae324a483..3ccc18318 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5355,6 +5355,14 @@
"profileSaved": "Profile saved",
"profilePartiallySaved": "Profile saved, but {field} could not be updated.",
"profileNothingSaved": "Nothing was saved. Fix {field} and try again.",
+ "profileRejection": {
+ "tooBig": "That value is above the range this field accepts.",
+ "tooSmall": "That value is below the range this field accepts.",
+ "invalidValue": "That is not one of the answers this field accepts.",
+ "invalidFormat": "That is not written in a form this field can read.",
+ "invalidType": "That value did not arrive in a form this field can store.",
+ "other": "This value was not accepted. Check it and try again."
+ },
"avatar": {
"title": "Profile photo",
"description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.",
diff --git a/messages/es.json b/messages/es.json
index 3fb6c1d8c..7ba0835c3 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5355,6 +5355,14 @@
"profileSaved": "Perfil guardado",
"profilePartiallySaved": "Perfil guardado, pero no se pudo actualizar {field}.",
"profileNothingSaved": "No se guardó nada. Corrige {field} e inténtalo de nuevo.",
+ "profileRejection": {
+ "tooBig": "El valor está por encima del rango que acepta este campo.",
+ "tooSmall": "El valor está por debajo del rango que acepta este campo.",
+ "invalidValue": "Esa no es una de las respuestas que acepta este campo.",
+ "invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.",
+ "invalidType": "El valor no llegó en un formato que este campo pueda guardar.",
+ "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo."
+ },
"avatar": {
"title": "Foto de perfil",
"description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.",
diff --git a/messages/fr.json b/messages/fr.json
index 08a2b848f..047d580a1 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5355,6 +5355,14 @@
"profileSaved": "Profil enregistré",
"profilePartiallySaved": "Profil enregistré, mais {field} n'a pas pu être mis à jour.",
"profileNothingSaved": "Rien n'a été enregistré. Corrigez {field} et réessayez.",
+ "profileRejection": {
+ "tooBig": "La valeur dépasse la plage acceptée par ce champ.",
+ "tooSmall": "La valeur est en dessous de la plage acceptée par ce champ.",
+ "invalidValue": "Ce n'est pas une des réponses acceptées par ce champ.",
+ "invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.",
+ "invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.",
+ "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez."
+ },
"avatar": {
"title": "Photo de profil",
"description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.",
diff --git a/messages/it.json b/messages/it.json
index bb61e24ba..c8b4e49d4 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5355,6 +5355,14 @@
"profileSaved": "Profilo salvato",
"profilePartiallySaved": "Profilo salvato, ma non è stato possibile aggiornare {field}.",
"profileNothingSaved": "Non è stato salvato nulla. Correggi {field} e riprova.",
+ "profileRejection": {
+ "tooBig": "Il valore supera l'intervallo accettato da questo campo.",
+ "tooSmall": "Il valore è inferiore all'intervallo accettato da questo campo.",
+ "invalidValue": "Questa non è una delle risposte accettate da questo campo.",
+ "invalidFormat": "Non è scritto in un formato che questo campo possa leggere.",
+ "invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.",
+ "other": "Questo valore non è stato accettato. Controllalo e riprova."
+ },
"avatar": {
"title": "Foto del profilo",
"description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.",
diff --git a/messages/ko.json b/messages/ko.json
index 37af1bca3..4eb23051d 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5355,6 +5355,14 @@
"profileSaved": "프로필을 저장했어요",
"profilePartiallySaved": "프로필을 저장했지만 {field} 항목은 업데이트하지 못했어요.",
"profileNothingSaved": "아무것도 저장하지 못했어요. {field} 항목을 고친 뒤 다시 시도해 주세요.",
+ "profileRejection": {
+ "tooBig": "이 항목이 받는 범위보다 큰 값이에요.",
+ "tooSmall": "이 항목이 받는 범위보다 작은 값이에요.",
+ "invalidValue": "이 항목이 받는 답변이 아니에요.",
+ "invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.",
+ "invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.",
+ "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요."
+ },
"avatar": {
"title": "프로필 사진",
"description": "직접 운영하는 서버에 저장되는 사진을 올려요. 외부 아바타 서비스 대신 앱 전체에 표시돼요.",
diff --git a/messages/pl.json b/messages/pl.json
index da7723c8b..ae08e819e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5355,6 +5355,14 @@
"profileSaved": "Profil zapisany",
"profilePartiallySaved": "Profil zapisany, ale nie udało się zaktualizować pola {field}.",
"profileNothingSaved": "Nic nie zostało zapisane. Popraw {field} i spróbuj ponownie.",
+ "profileRejection": {
+ "tooBig": "Wartość jest powyżej zakresu, który przyjmuje to pole.",
+ "tooSmall": "Wartość jest poniżej zakresu, który przyjmuje to pole.",
+ "invalidValue": "To nie jest jedna z odpowiedzi, które przyjmuje to pole.",
+ "invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.",
+ "invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.",
+ "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie."
+ },
"avatar": {
"title": "Zdjęcie profilowe",
"description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.",
diff --git a/src/__tests__/profile-rejection-surface-guard.test.ts b/src/__tests__/profile-rejection-surface-guard.test.ts
new file mode 100644
index 000000000..7bed465fd
--- /dev/null
+++ b/src/__tests__/profile-rejection-surface-guard.test.ts
@@ -0,0 +1,135 @@
+/**
+ * Every client that writes the profile has to read the answer.
+ *
+ * `applyProfileUpdate` writes field by field: a body where some fields
+ * validate and some do not is answered with 200 and a `rejectedFields`
+ * list naming what was dropped. A client that treats any 200 as a clean
+ * save discards the person's correction and says nothing — which is
+ * exactly what the onboarding baseline step did, for as long as the
+ * route has behaved this way.
+ *
+ * That is a whole-class defect, not one screen's bug, so the guard is
+ * on the class: enumerate every surface that writes to either profile
+ * route and require each one either to read `rejectedFields`, or to
+ * send a body the partial arm cannot reach.
+ *
+ * Its limit, written down so nobody mistakes it for a proof: it matches
+ * a URL literal next to a mutating method. A future submitter that
+ * builds its path by concatenation, or routes through a helper that
+ * takes the path as an argument, would not be seen. The set below is
+ * frozen so that adding one has to be a deliberate edit here.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+/** The two routes that answer with `rejectedFields`. */
+const PROFILE_ROUTES = ["/api/auth/profile", "/api/user/profile"];
+
+function sourceFiles(): string[] {
+ return walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.startsWith("app/api/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
+ .sort();
+}
+
+function read(rel: string): string {
+ return readFileSync(join(SRC, rel), "utf8");
+}
+
+/**
+ * A file counts as a submitter when a profile-route literal and a
+ * mutating method literal both appear within one request expression —
+ * approximated as the 400 characters following the URL, which covers
+ * the `fetch(url, { method, headers, body })` shape every caller in the
+ * tree uses.
+ */
+function submitters(): string[] {
+ const found: string[] = [];
+ for (const rel of sourceFiles()) {
+ const text = read(rel);
+ for (const route of PROFILE_ROUTES) {
+ let at = text.indexOf(`"${route}"`);
+ while (at !== -1) {
+ const window = text.slice(at, at + 400);
+ if (/method:\s*"(PUT|PATCH)"/.test(window)) {
+ found.push(rel);
+ at = -1;
+ break;
+ }
+ at = text.indexOf(`"${route}"`, at + 1);
+ }
+ if (found[found.length - 1] === rel) break;
+ }
+ }
+ return [...new Set(found)].sort();
+}
+
+/**
+ * Submitters whose body carries exactly ONE field. The partial arm
+ * needs at least one field to accept alongside at least one to refuse,
+ * so a one-field body is answered either with a clean 200 or with the
+ * 422 `profile.update.nothingSaved` — never with `rejectedFields`.
+ * These already surface the 422 as a save error.
+ *
+ * The claim is not taken on trust: the body literal is counted below.
+ */
+const SINGLE_FIELD_SUBMITTERS = [
+ "components/settings/date-format-select.tsx",
+ "components/settings/mood-reminder-card.tsx",
+ "components/settings/time-format-select.tsx",
+];
+
+/** Submitters that send several fields and must read the answer. */
+const MULTI_FIELD_SUBMITTERS = [
+ "components/onboarding/baseline-form-utils.ts",
+ "components/settings/account-section/index.tsx",
+];
+
+describe("every profile submitter answers a rejected field", () => {
+ it("finds the submitters at all", () => {
+ // A guard that matches nothing is green for the wrong reason.
+ expect(submitters().length).toBeGreaterThan(0);
+ });
+
+ it("keeps the submitter set frozen", () => {
+ expect(submitters()).toEqual(
+ [...SINGLE_FIELD_SUBMITTERS, ...MULTI_FIELD_SUBMITTERS].sort(),
+ );
+ });
+
+ it.each(MULTI_FIELD_SUBMITTERS)("%s reads rejectedFields", (rel) => {
+ const text = read(rel);
+ expect(text).toMatch(/rejectedFields/);
+ // And renders them per field, rather than only counting them. The
+ // matcher demands a CALL: an import of the helper next to a body
+ // that never calls it is the shape this whole guard exists to
+ // catch, and matching the bare name would pass on it.
+ const calls = [...text.matchAll(/describeRejectedProfileFields\s*\(/g)];
+ expect(calls.length).toBeGreaterThan(0);
+ });
+
+ it.each(SINGLE_FIELD_SUBMITTERS)("%s sends exactly one field", (rel) => {
+ const text = read(rel);
+ const bodies = [
+ ...text.matchAll(/body:\s*JSON\.stringify\(\{([^}]*)\}\)/g),
+ ];
+ expect(bodies.length).toBeGreaterThan(0);
+ for (const [, inner] of bodies) {
+ const keys = inner
+ .split(",")
+ .map((part) => part.trim())
+ .filter(Boolean);
+ // One key, so there is never a valid sibling to save alongside an
+ // invalid field — the arm that answers with `rejectedFields`
+ // cannot be reached from here.
+ expect(keys).toHaveLength(1);
+ }
+ });
+});
diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts
index b42229280..a90ff2dbb 100644
--- a/src/__tests__/unit-preference-display-guard.test.ts
+++ b/src/__tests__/unit-preference-display-guard.test.ts
@@ -56,9 +56,19 @@ const TARGET_SURFACES = [
* the same failure the target block guards against — a converted field
* over a canonical save silently rewrites the stored height.
*/
+/**
+ * One entry per surface. A surface is a group of files because the
+ * onboarding step keeps the preference and the adapter where the save
+ * happens and renders the control from its own inputs file — the three
+ * conditions below are asserted across the group, not per file, so
+ * splitting a form does not read as an un-wire.
+ */
const HEIGHT_SURFACES = [
- join(SRC, "components", "onboarding", "baseline-form.tsx"),
- join(SRC, "components", "settings", "account-section", "index.tsx"),
+ [
+ join(SRC, "components", "onboarding", "baseline-form.tsx"),
+ join(SRC, "components", "onboarding", "baseline-fields.tsx"),
+ ],
+ [join(SRC, "components", "settings", "account-section", "index.tsx")],
];
const HEIGHT_CONTROL = join(
@@ -153,21 +163,25 @@ describe("unit-preference display guard", () => {
// from one form and it falls back to centimetres while its sibling
// shows feet and inches.
const offenders: string[] = [];
- for (const file of HEIGHT_SURFACES) {
- const src = readFileSync(file, "utf8");
+ for (const files of HEIGHT_SURFACES) {
+ const surface = files.join(" + ");
+ const src = files.map((file) => readFileSync(file, "utf8")).join("\n");
if (!src.includes("useUnitDisplay()")) {
- offenders.push(`${file}: no preference hook`);
+ offenders.push(`${surface}: no preference hook`);
}
if (!src.includes("resolveHeightUnitAdapter")) {
- offenders.push(`${file}: no height adapter`);
+ offenders.push(`${surface}: no height adapter`);
}
- if (!src.includes("HeightFieldControl")) {
- offenders.push(`${file}: hand-rolled height input`);
+ // Word-bounded: a renamed local wrapper whose name merely starts
+ // with the control's satisfies a substring check while rendering
+ // something else entirely.
+ if (!/\bHeightFieldControl\b/.test(src)) {
+ offenders.push(`${surface}: hand-rolled height input`);
}
// The canonical centimetre guardrails must come from the
// adapter's inward-rounded bounds, never from a literal.
if (/min=\{50\}|max=\{300\}/.test(src)) {
- offenders.push(`${file}: hardcoded centimetre guardrail`);
+ offenders.push(`${surface}: hardcoded centimetre guardrail`);
}
}
expect(offenders).toEqual([]);
diff --git a/src/components/forms/field-error.tsx b/src/components/forms/field-error.tsx
new file mode 100644
index 000000000..5a720a786
--- /dev/null
+++ b/src/components/forms/field-error.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from "react";
+
+/**
+ * The error slot that belongs to one input.
+ *
+ * Every form that writes the profile shows a refused field the same
+ * way, or the same rejection reads differently depending on where the
+ * person happened to be standing. This is that one way: a destructive
+ * sentence directly under the control, carrying an id the control
+ * points `aria-describedby` at, so the reason is announced with the
+ * field rather than only painted next to it.
+ *
+ * `role="alert"` is deliberate. The sentence appears in response to a
+ * save the person just asked for, and a person who has already moved
+ * focus past the field would otherwise never learn the value did not
+ * land.
+ */
+export function FieldError({
+ id,
+ message,
+}: {
+ /** Conventionally `-error`. */
+ id: string;
+ message?: ReactNode;
+}) {
+ if (!message) return null;
+ return (
+
+ {message}
+
+ );
+}
diff --git a/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx b/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx
new file mode 100644
index 000000000..57aec24f9
--- /dev/null
+++ b/src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx
@@ -0,0 +1,175 @@
+/**
+ * A rejection the server names has to reach the input it belongs to.
+ *
+ * `PUT /api/auth/profile` writes field by field and reports the fields
+ * it refused. The onboarding step read that report only far enough to
+ * flash a toast and then walked the person to the next screen, so an
+ * out-of-range height was gone by the time anyone could act on it and
+ * the wizard counted the account as set up regardless.
+ *
+ * This runs the whole strip — build the body, PUT it over a stubbed
+ * `fetch`, read the answer, render the real inputs with what came back
+ * — because the gap was in the join, not in either end. The English
+ * bundle is the shipped one, so a sentence that reads as a key or
+ * leaks validator prose fails here.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { getCachedMessages } from "@/lib/i18n/load-locale";
+import { resolveKey } from "@/lib/i18n/resolve-key";
+import {
+ EMPTY_HEIGHT_DRAFT,
+ resolveHeightUnitAdapter,
+} from "@/lib/profile/height-unit-display";
+import { BaselineFields } from "../baseline-fields";
+import {
+ baselineFieldLabelKeys,
+ buildBaselineProfileBody,
+ describeBaselineSaveOutcome,
+ putBaselineProfile,
+} from "../baseline-form-utils";
+
+/** A `t` backed by the shipped bundle — no stand-in strings. */
+function translator(locale: "en" | "de") {
+ const bundle = getCachedMessages(locale);
+ if (!bundle) throw new Error(`No cached bundle for ${locale}`);
+ return (key: string, params?: Record) => {
+ const raw = resolveKey(bundle as Record, key);
+ if (raw === undefined) return key;
+ return Object.entries(params ?? {}).reduce(
+ (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
+ raw,
+ );
+ };
+}
+
+const FILLED_FORM = {
+ displayName: "Robin",
+ dateOfBirth: "1988-04-02",
+ gender: "OTHER",
+};
+
+function stubProfilePut(status: number, body: unknown) {
+ vi.stubGlobal("fetch", () =>
+ Promise.resolve(
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ }),
+ ),
+ );
+}
+
+/** Render the step's inputs exactly as the step renders them. */
+function renderFields(errors: Record) {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("a refused baseline field reaches its own input", () => {
+ it("paints the reason in the height field's slot and holds the step", async () => {
+ stubProfilePut(200, {
+ data: {
+ id: "u1",
+ displayName: "Robin",
+ rejectedFields: [{ path: "heightCm", code: "too_big" }],
+ },
+ error: null,
+ });
+
+ const en = translator("en");
+ const outcome = describeBaselineSaveOutcome(
+ await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 940)),
+ en,
+ baselineFieldLabelKeys(false),
+ );
+
+ // A field was refused: the wizard stays put and does not stamp the
+ // account as finished behind the person's back.
+ expect(outcome.advance).toBe(false);
+ expect(outcome.fieldErrors).toEqual({
+ heightCm: en("settings.profileRejection.tooBig"),
+ });
+
+ // What the outcome produced is what the input renders — no second
+ // mapping in between for a rename to fall through.
+ const html = renderFields(outcome.fieldErrors);
+ expect(html).toContain(en("settings.profileRejection.tooBig"));
+ // Neither a bare key nor the validator's own vocabulary.
+ expect(html).not.toContain("settings.profileRejection");
+ expect(html).not.toContain("too_big");
+ // Announced with the input, not merely painted beside it.
+ expect(html).toContain('id="ob-baseline-height-error"');
+ expect(html).toContain('aria-describedby="ob-baseline-height-error"');
+ expect(html).toContain('aria-invalid="true"');
+ });
+
+ it("leaves every accepted field's slot empty", async () => {
+ stubProfilePut(200, { data: { id: "u1" }, error: null });
+
+ const outcome = describeBaselineSaveOutcome(
+ await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, 178)),
+ translator("en"),
+ baselineFieldLabelKeys(false),
+ );
+
+ expect(outcome.advance).toBe(true);
+ expect(outcome.fieldErrors).toEqual({});
+ // (`aria-invalid:` also appears inside the primitives' Tailwind
+ // class strings, so the assertion is on the rendered attribute.)
+ const html = renderFields({});
+ expect(html).not.toContain('aria-invalid="true"');
+ expect(html).not.toContain('role="alert"');
+ });
+
+ it("names every refused field, not only the first", async () => {
+ stubProfilePut(422, {
+ data: null,
+ error: 'Nothing was saved. Fix the "dateOfBirth" field and try again.',
+ meta: { errorCode: "profile.update.nothingSaved" },
+ details: {
+ issues: [
+ { path: "dateOfBirth", code: "invalid_value" },
+ { path: "gender", code: "invalid_value" },
+ ],
+ },
+ });
+
+ const outcome = describeBaselineSaveOutcome(
+ await putBaselineProfile(buildBaselineProfileBody(FILLED_FORM, null)),
+ translator("de"),
+ baselineFieldLabelKeys(false),
+ );
+
+ expect(outcome.advance).toBe(false);
+ expect(Object.keys(outcome.fieldErrors).sort()).toEqual([
+ "dateOfBirth",
+ "gender",
+ ]);
+ // The person's language, not the validator's.
+ const de = translator("de");
+ expect(outcome.fieldErrors.gender).toBe(
+ de("settings.profileRejection.invalidValue"),
+ );
+ expect(outcome.fieldErrors.gender).not.toContain("invalid_value");
+ });
+});
diff --git a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts
index 198ed664f..8094ddbe0 100644
--- a/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts
+++ b/src/components/onboarding/__tests__/baseline-form-rejected-fields.test.ts
@@ -60,7 +60,7 @@ afterEach(() => {
});
describe("onboarding baseline profile save", () => {
- it("names the rejected field and still lets the accepted ones stand", async () => {
+ it("names the rejected field, keeps the accepted ones, and holds the step", async () => {
const calls = stubProfilePut(200, {
data: {
id: "u1",
@@ -88,14 +88,21 @@ describe("onboarding baseline profile save", () => {
gender: "OTHER",
});
- // The accepted fields are saved and the wizard moves on...
- expect(outcome.advance).toBe(true);
- // ...but the declined one is said out loud, by the name this screen
- // put next to the input.
+ // The accepted fields are saved, and the declined one is said out
+ // loud by the name this screen put next to the input.
expect(outcome.notice).toEqual({
tone: "warning",
message: "Profile saved, but Height (cm) could not be updated.",
});
+ // This assertion was `true` until the rejection reached the input
+ // it belongs to. Advancing here posted `step: 4`, stamped the
+ // account as set up and replaced the only screen that could show
+ // the refused value — a toast on the way out was the whole notice
+ // the person got. The step now waits for a correction.
+ expect(outcome.advance).toBe(false);
+ expect(outcome.fieldErrors.heightCm).toBe(
+ "That value is above the range this field accepts.",
+ );
});
it("says nothing when every field was accepted", async () => {
@@ -107,7 +114,7 @@ describe("onboarding baseline profile save", () => {
baselineFieldLabelKeys(false),
);
- expect(outcome).toEqual({ advance: true, notice: null });
+ expect(outcome).toEqual({ advance: true, notice: null, fieldErrors: {} });
});
it("names the blocking field and holds the step when nothing was saved", async () => {
diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx
new file mode 100644
index 000000000..5eaf113e5
--- /dev/null
+++ b/src/components/onboarding/baseline-fields.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { DateField } from "@/components/ui/date-field";
+import { FieldGroup } from "@/components/ui/field-group";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { HeightFieldControl } from "@/components/profile/height-field-control";
+import { useTranslations } from "@/lib/i18n/context";
+import type {
+ HeightDraft,
+ HeightUnitAdapter,
+} from "@/lib/profile/height-unit-display";
+
+/**
+ * The four profile inputs of the onboarding baseline step.
+ *
+ * Split out of `baseline-form.tsx` so the markup can be rendered on its
+ * own against a server answer. The step's whole defect lived between
+ * the response and these inputs — the server named a field it refused
+ * and nothing on screen changed — so the seam a test needs is exactly
+ * here: field values in, field rejections in, markup out.
+ *
+ * Each field id doubles as the id of its error paragraph
+ * (`-error`), which is what the control points `aria-describedby`
+ * at, so a rejection is announced with the input rather than only
+ * painted next to it.
+ */
+
+export interface BaselineFieldValues {
+ displayName: string;
+ height: HeightDraft;
+ dateOfBirth: string;
+ gender: string;
+}
+
+/**
+ * Per-field sentences keyed by the SCHEMA field name the server uses
+ * (`heightCm`, not `height`) — the key the rejection arrives under, so
+ * nothing has to be renamed between the wire and the slot.
+ */
+export type BaselineFieldErrors = Partial<
+ Record<"displayName" | "heightCm" | "dateOfBirth" | "gender", string>
+>;
+
+const IDS = {
+ displayName: "ob-baseline-display-name",
+ heightCm: "ob-baseline-height",
+ dateOfBirth: "ob-baseline-dob",
+ gender: "ob-baseline-gender",
+} as const;
+
+export function BaselineFields({
+ value,
+ onChange,
+ heightAdapter,
+ errors,
+}: {
+ value: BaselineFieldValues;
+ onChange: (
+ key: K,
+ next: BaselineFieldValues[K],
+ ) => void;
+ heightAdapter: HeightUnitAdapter;
+ errors: BaselineFieldErrors;
+}) {
+ const { t } = useTranslations();
+
+ return (
+
+ );
+}
diff --git a/src/components/onboarding/baseline-form-utils.ts b/src/components/onboarding/baseline-form-utils.ts
index 634d5ffc9..c51a95722 100644
--- a/src/components/onboarding/baseline-form-utils.ts
+++ b/src/components/onboarding/baseline-form-utils.ts
@@ -1,6 +1,7 @@
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
describeRejectedProfileField,
+ describeRejectedProfileFields,
type RejectedProfileField,
} from "@/lib/profile/rejected-fields";
import { toProfileSex } from "@/lib/profile/sex";
@@ -15,14 +16,19 @@ import { toProfileSex } from "@/lib/profile/sex";
* screen with nothing said, and the person walked away believing it was
* stored.
*
- * The settings account form already answers this question (see
- * `handleSaveProfile` in `src/components/settings/account-section`):
- * name the field in the person's own words, keep validator prose off
- * the screen, and only claim a clean save when there is one. The two
- * screens share the field-naming helper and the two sentences, so the
- * same rejection reads the same wherever it happens; only the labels
- * differ, because each screen calls the fields what its own inputs
- * call them.
+ * The settings account form answers the same question the same way
+ * (see `handleSaveProfile` in `src/components/settings/account-section`):
+ * name the field in the person's own words, put the reason in that
+ * field's own slot, keep validator prose off the screen, and only claim
+ * a clean save when there is one. The two screens share the
+ * field-naming helper and the sentences, so the same rejection reads
+ * the same wherever it happens; only the labels differ, because each
+ * screen calls the fields what its own inputs call them.
+ *
+ * A refused field also stops the wizard. Walking on would stamp
+ * `onboardingCompletedAt` over a step the person has not finished, and
+ * the only screen that could have shown them the refused value is the
+ * one they just left.
*/
/** The parts of the profile response this step reads. */
@@ -41,6 +47,12 @@ export interface BaselineSaveOutcome {
advance: boolean;
/** A ready-to-show sentence, already localized. Null on a clean save. */
notice: { tone: "warning" | "error"; message: string } | null;
+ /**
+ * One localized reason per refused field, keyed by the schema field
+ * name so the step can drop each into the slot under its own input.
+ * Empty on a clean save and on a failure that named no field.
+ */
+ fieldErrors: Record;
}
/**
@@ -125,22 +137,25 @@ export function describeBaselineSaveOutcome(
if (res.ok) {
const rejected = res.body?.data?.rejectedFields;
const field = describeRejectedProfileField(rejected, t, labelKeys);
- if (!field) return { advance: true, notice: null };
+ if (!field) return { advance: true, notice: null, fieldErrors: {} };
+ // Some fields landed, at least one did not. The wizard stops here:
+ // the accepted values are stored and stay in the inputs, the
+ // refused ones say why under their own control, and the person can
+ // correct and press the same button again. Moving on would mark
+ // the account set up over a value nobody ever saw refused.
return {
- advance: true,
+ advance: false,
notice: {
tone: "warning",
message: t("settings.profilePartiallySaved", { field }),
},
+ fieldErrors: describeRejectedProfileFields(rejected, t),
};
}
const errorCode = res.body?.meta?.errorCode;
- const blockingField = describeRejectedProfileField(
- res.body?.details?.issues,
- t,
- labelKeys,
- );
+ const issues = res.body?.details?.issues;
+ const blockingField = describeRejectedProfileField(issues, t, labelKeys);
if (errorCode === "profile.update.nothingSaved" && blockingField) {
return {
@@ -149,12 +164,14 @@ export function describeBaselineSaveOutcome(
tone: "error",
message: t("settings.profileNothingSaved", { field: blockingField }),
},
+ fieldErrors: describeRejectedProfileFields(issues, t),
};
}
return {
advance: false,
notice: { tone: "error", message: localizedCode(errorCode, t) },
+ fieldErrors: {},
};
}
diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx
index 7576be392..6bb195692 100644
--- a/src/components/onboarding/baseline-form.tsx
+++ b/src/components/onboarding/baseline-form.tsx
@@ -8,17 +8,10 @@ import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { DateField } from "@/components/ui/date-field";
-import { FieldGroup } from "@/components/ui/field-group";
-import { Input } from "@/components/ui/input";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { HeightFieldControl } from "@/components/profile/height-field-control";
+ BaselineFields,
+ type BaselineFieldErrors,
+} from "@/components/onboarding/baseline-fields";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
EMPTY_HEIGHT_DRAFT,
@@ -100,6 +93,10 @@ export function BaselineForm() {
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
+ // Refusals from the last save, one sentence per field. Cleared on
+ // every attempt so a slot never keeps a reason the server no longer
+ // has, and cleared per field as it is edited.
+ const [fieldErrors, setFieldErrors] = useState({});
// v1.17.1 — optional anamnesis (conditions + allergies). Persisted
// through the existing encrypted self-context path
@@ -137,11 +134,23 @@ export function BaselineForm() {
value: BaselineFormState[K],
) {
setForm((prev) => ({ ...prev, [key]: value }));
+ // The person is answering the objection; the objection goes away
+ // while they type rather than sitting under a value they already
+ // corrected. `height` is the draft's name here and `heightCm` the
+ // schema's — the same field, so editing either box clears it.
+ const slot = key === "height" ? "heightCm" : key;
+ setFieldErrors((prev) => {
+ if (!(slot in prev)) return prev;
+ const next = { ...prev };
+ delete next[slot as keyof BaselineFieldErrors];
+ return next;
+ });
}
async function advance(opts: { saveProfile: boolean }) {
if (saving) return;
setSaving(true);
+ setFieldErrors({});
try {
if (opts.saveProfile) {
const profileBody = buildBaselineProfileBody(
@@ -160,9 +169,12 @@ export function BaselineForm() {
show(outcome.notice.message);
}
if (!outcome.advance) {
- // Nothing was written. Staying on the step is the point —
- // the person has a named field to fix and the values they
- // typed are still in front of them.
+ // A field was refused. Staying on the step is the point —
+ // each refused input now says why under itself, the values
+ // the person typed are still in front of them, and the
+ // account is not stamped as set up over a value that never
+ // landed.
+ setFieldErrors(outcome.fieldErrors);
setSaving(false);
return;
}
@@ -201,91 +213,12 @@ export function BaselineForm() {
-
+ void;
autoComplete?: string;
enterKeyHint?: "next";
+ /** Marks every box of the control as refused. */
+ invalid?: boolean;
+ /** Id of the error paragraph the control's boxes point at. */
+ describedBy?: string;
}
export function HeightFieldControl({
@@ -40,6 +44,8 @@ export function HeightFieldControl({
onChange,
autoComplete,
enterKeyHint,
+ invalid,
+ describedBy,
}: HeightFieldControlProps) {
const { t } = useTranslations();
const { bounds } = adapter;
@@ -58,6 +64,8 @@ export function HeightFieldControl({
max={bounds.cm.max}
step={0.1}
autoComplete={autoComplete}
+ aria-invalid={invalid || undefined}
+ aria-describedby={describedBy}
/>
);
}
@@ -77,6 +85,8 @@ export function HeightFieldControl({
max={bounds.feet.max}
step={1}
autoComplete={autoComplete}
+ aria-invalid={invalid || undefined}
+ aria-describedby={describedBy}
aria-label={t("common.heightFeet")}
/>
m[1]),
+ ),
+ ].sort();
+}
+
+/** The slots an input clears as it is edited. */
+function clearedSlots(): string[] {
+ return [
+ ...new Set(
+ [...SOURCE.matchAll(/clearFieldError\("(\w+)"\)/g)].map((m) => m[1]),
+ ),
+ ].sort();
+}
+
+describe("a rejected profile field clears as it is edited", () => {
+ it("drops the edited field's sentence and keeps the others", () => {
+ const refused = {
+ heightCm: "That value is above the range this field accepts.",
+ gender: "That value is not one this field accepts.",
+ };
+
+ const afterHeightEdit = clearRejectedField(refused, "heightCm");
+ expect(afterHeightEdit.heightCm).toBeUndefined();
+ // The gender value was not touched, so its refusal still answers
+ // the value that is still in the box.
+ expect(afterHeightEdit.gender).toBe(refused.gender);
+
+ // Editing the other field clears only its own slot in turn.
+ const afterBothEdits = clearRejectedField(afterHeightEdit, "gender");
+ expect(afterBothEdits).toEqual({});
+ // The original map is never mutated — it is React state.
+ expect(refused.heightCm).toBeDefined();
+ });
+
+ it("leaves a map with nothing in that slot untouched", () => {
+ const refused = { gender: "That value is not one this field accepts." };
+ // Same reference: typing in a field that was never refused must not
+ // hand React a new object every keystroke.
+ expect(clearRejectedField(refused, "email")).toBe(refused);
+ });
+
+ it("clears every slot the form can paint", () => {
+ // A guard that matches nothing is green for the wrong reason.
+ expect(renderedSlots().length).toBeGreaterThan(0);
+ expect(clearedSlots()).toEqual(renderedSlots());
+ });
+
+ it("paints a slot for every field the server can refuse", () => {
+ // The label map is the set of paths `applyProfileUpdate` names back
+ // to this screen; a path with no slot has nowhere to land.
+ expect(renderedSlots()).toEqual(
+ Object.keys(PROFILE_FIELD_LABEL_KEYS).sort(),
+ );
+ });
+});
diff --git a/src/components/settings/account-section/account-section-utils.ts b/src/components/settings/account-section/account-section-utils.ts
index d37ab9683..ba90a1ed5 100644
--- a/src/components/settings/account-section/account-section-utils.ts
+++ b/src/components/settings/account-section/account-section-utils.ts
@@ -67,3 +67,25 @@ export function statusText(
* uses the default label map, which points at the labels these inputs
* already carry.
*/
+
+/**
+ * Drop one field's refusal as the person edits that field.
+ *
+ * A refusal answers a value. Once the value changes it answers nothing,
+ * so it goes instead of sitting under an input that has already been
+ * corrected — the same rule the onboarding baseline step applies in its
+ * `patch` helper. Only the edited slot is dropped: the other fields the
+ * same save refused were not touched, so their sentences still stand.
+ *
+ * The map is returned unchanged when the slot holds nothing, so typing
+ * in a field that was never refused does not re-render the form.
+ */
+export function clearRejectedField(
+ errors: Record,
+ slot: string,
+): Record {
+ if (!(slot in errors)) return errors;
+ const next = { ...errors };
+ delete next[slot];
+ return next;
+}
diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx
index 1dd03c3f4..21335c2b7 100644
--- a/src/components/settings/account-section/index.tsx
+++ b/src/components/settings/account-section/index.tsx
@@ -37,9 +37,12 @@ import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
describeRejectedProfileField,
+ describeRejectedProfileFields,
type RejectedProfileField,
} from "@/lib/profile/rejected-fields";
+import { FieldError } from "@/components/forms/field-error";
import {
+ clearRejectedField,
resolveInitialTimezone,
statusText,
type StatusMessage,
@@ -90,6 +93,20 @@ export function AccountSection() {
const [saveMsgType, setSaveMsgType] = useState<
"success" | "warning" | "error" | null
>(null);
+ // One sentence per field the last save refused, keyed by the schema
+ // name the server sent. The banner above the button says a save was
+ // partial; these say which value was not stored and why, under the
+ // input holding it. Cleared at the start of every save, and per field
+ // as that field is edited.
+ const [fieldErrors, setFieldErrors] = useState>({});
+ // The person is answering the objection; the objection goes away
+ // while they type rather than sitting under a value they already
+ // corrected. The slot is the schema name the server sent, which is
+ // what the sentence under the input is keyed by — the height boxes
+ // hold a `height` draft but name the `heightCm` slot.
+ function clearFieldError(slot: string) {
+ setFieldErrors((prev) => clearRejectedField(prev, slot));
+ }
// v1.23 — passkey + second-factor management moved to the dedicated
// /settings/security hub so "how I secure my account" reads as one place.
@@ -172,6 +189,7 @@ export function AccountSection() {
setSaving(true);
setSaveMsg(null);
setSaveMsgType(null);
+ setFieldErrors({});
const savedProfile = {
email: email.trim(),
height,
@@ -234,6 +252,7 @@ export function AccountSection() {
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
});
setSaveMsgType("warning");
+ setFieldErrors(describeRejectedProfileFields(rejected, t));
await refetch();
setSeededUserId(null);
} else {
@@ -272,6 +291,7 @@ export function AccountSection() {
} | null;
const errorCode = json?.meta?.errorCode;
const field = describeRejectedProfileField(json?.details?.issues, t);
+ setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
if (errorCode === "profile.update.nothingSaved" && field) {
setSaveMsg({ key: "settings.profileNothingSaved", params: { field } });
} else if (errorCode) {
@@ -336,12 +356,18 @@ export function AccountSection() {
id="email"
type="email"
value={email}
- onChange={(e) => setEmail(e.target.value)}
+ onChange={(e) => {
+ setEmail(e.target.value);
+ clearFieldError("email");
+ }}
placeholder={t("auth.emailPlaceholder")}
maxLength={320}
autoComplete="email"
enterKeyHint="next"
+ aria-invalid={fieldErrors.email ? true : undefined}
+ aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
+
@@ -351,7 +377,14 @@ export function AccountSection() {
setGender(e.target.value)}
+ onChange={(e) => {
+ setGender(e.target.value);
+ clearFieldError("gender");
+ }}
+ aria-invalid={fieldErrors.gender ? true : undefined}
+ aria-describedby={
+ fieldErrors.gender ? "gender-error" : undefined
+ }
>
@@ -361,6 +394,7 @@ export function AccountSection() {