From ae20ee3d9dd446e5562d4087c67f632c96e3de62 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 9 Sep 2026 23:16:50 +0200
Subject: [PATCH 1/6] Split the onboarding baseline inputs into their own
component
The step's profile fieldset moves to `baseline-fields.tsx` unchanged.
The markup can now be rendered against a server answer on its own,
which is where the next commit puts a test.
---
src/components/onboarding/baseline-fields.tsx | 143 ++++++++++++++++++
src/components/onboarding/baseline-form.tsx | 102 +------------
2 files changed, 149 insertions(+), 96 deletions(-)
create mode 100644 src/components/onboarding/baseline-fields.tsx
diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx
new file mode 100644
index 000000000..59ef5930a
--- /dev/null
+++ b/src/components/onboarding/baseline-fields.tsx
@@ -0,0 +1,143 @@
+"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, markup out.
+ */
+
+export interface BaselineFieldValues {
+ displayName: string;
+ height: HeightDraft;
+ dateOfBirth: string;
+ 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,
+}: {
+ value: BaselineFieldValues;
+ onChange: (
+ key: K,
+ next: BaselineFieldValues[K],
+ ) => void;
+ heightAdapter: HeightUnitAdapter;
+}) {
+ const { t } = useTranslations();
+
+ return (
+
+ );
+}
diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx
index 7576be392..8d81811dc 100644
--- a/src/components/onboarding/baseline-form.tsx
+++ b/src/components/onboarding/baseline-form.tsx
@@ -8,17 +8,7 @@ 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";
+import { BaselineFields } from "@/components/onboarding/baseline-fields";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
EMPTY_HEIGHT_DRAFT,
@@ -201,91 +191,11 @@ export function BaselineForm() {
-
+
Date: Wed, 9 Sep 2026 23:21:21 +0200
Subject: [PATCH 2/6] Show a refused profile field under the input it belongs
to
The onboarding baseline step flashed a toast naming one refused field
and walked on to the done screen, stamping the account as set up over
a value the server never stored. The value the person typed was gone
with the step.
A refused field now writes its reason into that field's own error
slot, marks the control invalid, and holds the step until the person
corrects it. Every refused field is named, not only the first, and the
reason comes from the validator code rather than the validator's own
prose, in all seven shipped languages.
---
messages/de.json | 8 +
messages/en.json | 8 +
messages/es.json | 8 +
messages/fr.json | 8 +
messages/it.json | 8 +
messages/ko.json | 8 +
messages/pl.json | 8 +
src/components/forms/field-error.tsx | 32 ++++
.../baseline-fields-rejection-slots.test.tsx | 175 ++++++++++++++++++
.../baseline-form-rejected-fields.test.ts | 19 +-
src/components/onboarding/baseline-fields.tsx | 36 +++-
.../onboarding/baseline-form-utils.ts | 47 +++--
src/components/onboarding/baseline-form.tsx | 31 +++-
.../profile/height-field-control.tsx | 12 ++
src/components/ui/field-group.tsx | 7 +
src/lib/profile/rejected-fields.ts | 49 +++++
16 files changed, 438 insertions(+), 26 deletions(-)
create mode 100644 src/components/forms/field-error.tsx
create mode 100644 src/components/onboarding/__tests__/baseline-fields-rejection-slots.test.tsx
diff --git a/messages/de.json b/messages/de.json
index eef3089fe..2c77763c1 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -5353,6 +5353,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 e75e342a8..f4d924b7e 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5353,6 +5353,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 91f6187d8..da24ec7e2 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5353,6 +5353,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 23921c63d..f3f88618f 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -5353,6 +5353,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 e17e31091..a805995dc 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -5353,6 +5353,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 66d228404..c382284b8 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -5353,6 +5353,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 7ec4d5cbc..b05aea094 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -5353,6 +5353,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/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
index 59ef5930a..5eaf113e5 100644
--- a/src/components/onboarding/baseline-fields.tsx
+++ b/src/components/onboarding/baseline-fields.tsx
@@ -24,7 +24,12 @@ import type {
* 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, markup out.
+ * 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 {
@@ -34,6 +39,15 @@ export interface BaselineFieldValues {
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",
@@ -45,6 +59,7 @@ export function BaselineFields({
value,
onChange,
heightAdapter,
+ errors,
}: {
value: BaselineFieldValues;
onChange: (
@@ -52,6 +67,7 @@ export function BaselineFields({
next: BaselineFieldValues[K],
) => void;
heightAdapter: HeightUnitAdapter;
+ errors: BaselineFieldErrors;
}) {
const { t } = useTranslations();
@@ -63,6 +79,7 @@ export function BaselineFields({
htmlFor={IDS.displayName}
label={t("onboarding.baseline.displayNameLabel")}
hint={t("onboarding.baseline.displayNameHint")}
+ error={errors.displayName}
>
@@ -82,6 +103,7 @@ export function BaselineFields({
? t("onboarding.baseline.heightLabelFtIn")
: t("onboarding.baseline.heightLabel")
}
+ error={errors.heightCm}
>
onChange("height", next)}
autoComplete="off"
+ invalid={Boolean(errors.heightCm)}
+ describedBy={errors.heightCm ? `${IDS.heightCm}-error` : undefined}
/>
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 8d81811dc..6bb195692 100644
--- a/src/components/onboarding/baseline-form.tsx
+++ b/src/components/onboarding/baseline-form.tsx
@@ -8,7 +8,10 @@ import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
-import { BaselineFields } from "@/components/onboarding/baseline-fields";
+import {
+ BaselineFields,
+ type BaselineFieldErrors,
+} from "@/components/onboarding/baseline-fields";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
EMPTY_HEIGHT_DRAFT,
@@ -90,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
@@ -127,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(
@@ -150,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;
}
@@ -195,6 +217,7 @@ export function BaselineForm() {
value={form}
onChange={patch}
heightAdapter={heightAdapter}
+ errors={fieldErrors}
/>
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")}
/>
-error` so the control can point
+ * `aria-describedby` at it.
* - `labelAccessory` sits right-aligned in the label row (e.g. a char counter);
* when present the label row becomes a `justify-between` flex row.
*/
@@ -17,6 +21,7 @@ export function FieldGroup({
htmlFor,
label,
hint,
+ error,
labelAccessory,
className,
labelClassName,
@@ -25,6 +30,7 @@ export function FieldGroup({
htmlFor: string;
label: ReactNode;
hint?: ReactNode;
+ error?: ReactNode;
labelAccessory?: ReactNode;
className?: string;
labelClassName?: string;
@@ -46,6 +52,7 @@ export function FieldGroup({
)}
{children}
{hint ?
{hint}
: null}
+
);
}
diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts
index 12cd82a20..d4a2deef0 100644
--- a/src/lib/profile/rejected-fields.ts
+++ b/src/lib/profile/rejected-fields.ts
@@ -60,3 +60,52 @@ export function describeRejectedProfileField(
const labelKey = labelKeys[first.path];
return labelKey ? t(labelKey) : first.path;
}
+
+/**
+ * The sentence a person reads under the input, keyed by the validator
+ * code the server sent back. The code is the only reason that crosses
+ * the wire — `message` is the validator's own prose and is deliberately
+ * never shown — so each code gets one plain sentence saying what was
+ * wrong with the value, in the person's language.
+ *
+ * A code with no entry here falls back to `other`, which says the field
+ * was not accepted without inventing a reason. New Zod codes therefore
+ * degrade to something honest instead of to silence.
+ */
+const REJECTION_REASON_KEYS: Record = {
+ too_big: "settings.profileRejection.tooBig",
+ too_small: "settings.profileRejection.tooSmall",
+ invalid_value: "settings.profileRejection.invalidValue",
+ invalid_format: "settings.profileRejection.invalidFormat",
+ invalid_type: "settings.profileRejection.invalidType",
+};
+
+const REJECTION_FALLBACK_KEY = "settings.profileRejection.other";
+
+/**
+ * Turn a rejection list into one sentence per field, keyed by the
+ * schema path so a form can drop each one into the slot under the
+ * input it belongs to.
+ *
+ * Every refused field is carried, not just the first: a submission with
+ * two bad values that only names one sends the person round the loop
+ * twice. Where a field is refused for several reasons at once the first
+ * one wins — the others are restatements of the same wrong value.
+ *
+ * `labelKeys` is accepted for symmetry with
+ * `describeRejectedProfileField` and for the caller that wants to know
+ * which fields it can actually render; a path the calling screen has no
+ * input for still gets an entry, so nothing is dropped on the floor.
+ */
+export function describeRejectedProfileFields(
+ fields: RejectedProfileField[] | undefined,
+ t: (key: string, params?: Record) => string,
+): Record {
+ const out: Record = {};
+ for (const field of fields ?? []) {
+ if (out[field.path]) continue;
+ const key = REJECTION_REASON_KEYS[field.code] ?? REJECTION_FALLBACK_KEY;
+ out[field.path] = t(key);
+ }
+ return out;
+}
From e74987e819a90562e0707030380cd23598fcc9e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 9 Sep 2026 23:22:14 +0200
Subject: [PATCH 3/6] Give the settings profile form the same per-field
rejection slots
It shares the route and had the same gap: the banner named the first
refused field and nothing marked the input that held the value. Each
of the seven submitted fields now carries the reason in its own slot,
through the same component the onboarding step uses.
---
.../settings/account-section/index.tsx | 49 +++++++++++++++++++
1 file changed, 49 insertions(+)
diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx
index 1dd03c3f4..cd3370dd2 100644
--- a/src/components/settings/account-section/index.tsx
+++ b/src/components/settings/account-section/index.tsx
@@ -37,8 +37,10 @@ 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 {
resolveInitialTimezone,
statusText,
@@ -90,6 +92,11 @@ 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.
+ const [fieldErrors, setFieldErrors] = useState>({});
// 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 +179,7 @@ export function AccountSection() {
setSaving(true);
setSaveMsg(null);
setSaveMsgType(null);
+ setFieldErrors({});
const savedProfile = {
email: email.trim(),
height,
@@ -234,6 +242,7 @@ export function AccountSection() {
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
});
setSaveMsgType("warning");
+ setFieldErrors(describeRejectedProfileFields(rejected, t));
await refetch();
setSeededUserId(null);
} else {
@@ -272,6 +281,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) {
@@ -341,7 +351,10 @@ export function AccountSection() {
maxLength={320}
autoComplete="email"
enterKeyHint="next"
+ aria-invalid={fieldErrors.email ? true : undefined}
+ aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
+
@@ -352,6 +365,10 @@ export function AccountSection() {
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
+ aria-invalid={fieldErrors.gender ? true : undefined}
+ aria-describedby={
+ fieldErrors.gender ? "gender-error" : undefined
+ }
>
@@ -361,6 +378,7 @@ export function AccountSection() {
From a0f80eac8cade9142313dc914a670d1f3126af99 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 9 Sep 2026 23:23:22 +0200
Subject: [PATCH 4/6] Freeze the set of profile submitters that must read the
answer
A partial save is a whole-class trap: any 200 read as a clean save
throws the person's correction away. The guard enumerates every
surface writing to either profile route and requires each to call the
per-field describer, or to prove its body carries a single field, for
which the partial arm is unreachable.
---
.../profile-rejection-surface-guard.test.ts | 135 ++++++++++++++++++
1 file changed, 135 insertions(+)
create mode 100644 src/__tests__/profile-rejection-surface-guard.test.ts
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);
+ }
+ });
+});
From 4c858e76004a65f7d763105b4c4329fba3bb4b09 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 9 Sep 2026 23:29:00 +0200
Subject: [PATCH 5/6] Read the height-surface guard across a split form
The onboarding step keeps the preference and the adapter where the
save happens and renders the control from its inputs file, so the
three conditions belong to the surface, not to one file. The control
check is word-bounded now: a wrapper named after it passed a substring
check while rendering something else.
---
.../unit-preference-display-guard.test.ts | 32 +++++++++++++------
src/lib/profile/rejected-fields.ts | 9 +++---
2 files changed, 28 insertions(+), 13 deletions(-)
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/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts
index d4a2deef0..da0a3e604 100644
--- a/src/lib/profile/rejected-fields.ts
+++ b/src/lib/profile/rejected-fields.ts
@@ -92,10 +92,11 @@ const REJECTION_FALLBACK_KEY = "settings.profileRejection.other";
* twice. Where a field is refused for several reasons at once the first
* one wins — the others are restatements of the same wrong value.
*
- * `labelKeys` is accepted for symmetry with
- * `describeRejectedProfileField` and for the caller that wants to know
- * which fields it can actually render; a path the calling screen has no
- * input for still gets an entry, so nothing is dropped on the floor.
+ * No label map is needed: the sentence sits under the input, which
+ * already carries the label, and the key is the schema path so the
+ * calling screen can place it without a rename. A path the screen has
+ * no input for still gets an entry rather than being dropped, so the
+ * caller can decide what to do with it.
*/
export function describeRejectedProfileFields(
fields: RejectedProfileField[] | undefined,
From eea0f2a53209776dad5254210513d25e3af3726f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 10 Sep 2026 00:42:45 +0200
Subject: [PATCH 6/6] Clear a refused profile field as it is edited
The settings profile form painted the server's refusal under the input
and left it there until the next save, so a value the person had already
corrected still read as refused. The onboarding baseline step drops that
sentence while they type; the account card now does the same.
Only the edited field's slot goes: the other fields the same save
refused were not touched, so their sentences still stand. The guard
derives both sets from the form itself, so a new rejection slot without
a clear fails.
Review finding L3.
---
...count-section-field-error-clearing.test.ts | 83 +++++++++++++++++++
.../account-section/account-section-utils.ts | 22 +++++
.../settings/account-section/index.tsx | 49 ++++++++---
3 files changed, 144 insertions(+), 10 deletions(-)
create mode 100644 src/components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts
diff --git a/src/components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts b/src/components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts
new file mode 100644
index 000000000..9b48f313f
--- /dev/null
+++ b/src/components/settings/account-section/__tests__/account-section-field-error-clearing.test.ts
@@ -0,0 +1,83 @@
+/**
+ * A refusal the person has already answered must not keep standing.
+ *
+ * The settings profile form paints one sentence per field the last save
+ * declined. It cleared them only on the next submit, so a corrected
+ * value sat under the reason it was refused until the person pressed
+ * save again — the onboarding baseline step drops the sentence while
+ * they type (`patch` in `baseline-form.tsx`) and this form did not.
+ *
+ * Two things have to hold, and both are checked here because either one
+ * alone passes on the defect: the state rule (the edited field's
+ * sentence goes, its siblings stay), and the wiring (every slot the form
+ * renders actually names itself when its input changes). A helper that
+ * nothing calls is exactly the shape this guard exists to catch.
+ */
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+import { PROFILE_FIELD_LABEL_KEYS } from "@/lib/profile/rejected-fields";
+import { clearRejectedField } from "../account-section-utils";
+
+const SOURCE = readFileSync(resolve(__dirname, "../index.tsx"), "utf8");
+
+/** The slots the form renders a refusal sentence into. */
+function renderedSlots(): string[] {
+ return [
+ ...new Set(
+ [...SOURCE.matchAll(/message=\{fieldErrors\.(\w+)\}/g)].map((m) => 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 cd3370dd2..21335c2b7 100644
--- a/src/components/settings/account-section/index.tsx
+++ b/src/components/settings/account-section/index.tsx
@@ -42,6 +42,7 @@ import {
} from "@/lib/profile/rejected-fields";
import { FieldError } from "@/components/forms/field-error";
import {
+ clearRejectedField,
resolveInitialTimezone,
statusText,
type StatusMessage,
@@ -95,8 +96,17 @@ export function AccountSection() {
// 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.
+ // 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.
@@ -346,7 +356,10 @@ 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"
@@ -364,7 +377,10 @@ 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
@@ -390,7 +406,10 @@ export function AccountSection() {
idPrefix="height"
adapter={heightAdapter}
value={height}
- onChange={setHeight}
+ onChange={(next) => {
+ setHeight(next);
+ clearFieldError("heightCm");
+ }}
enterKeyHint="next"
invalid={Boolean(fieldErrors.heightCm)}
describedBy={fieldErrors.heightCm ? "height-error" : undefined}
@@ -411,7 +430,10 @@ export function AccountSection() {
{
+ setDateOfBirth(next);
+ clearFieldError("dateOfBirth");
+ }}
max={new Date().toISOString().slice(0, 10)}
aria-invalid={fieldErrors.dateOfBirth ? true : undefined}
aria-describedby={
@@ -477,7 +499,10 @@ export function AccountSection() {
setFullName(e.target.value)}
+ onChange={(e) => {
+ setFullName(e.target.value);
+ clearFieldError("fullName");
+ }}
placeholder={t("settings.identity.fullNamePlaceholder")}
maxLength={120}
autoComplete="name"
@@ -496,7 +521,10 @@ export function AccountSection() {
setInsurerName(e.target.value)}
+ onChange={(e) => {
+ setInsurerName(e.target.value);
+ clearFieldError("insurerName");
+ }}
placeholder={t("settings.identity.insurerPlaceholder")}
maxLength={120}
aria-invalid={fieldErrors.insurerName ? true : undefined}
@@ -516,9 +544,10 @@ export function AccountSection() {
- setInsuranceNumber(e.target.value.toUpperCase())
- }
+ onChange={(e) => {
+ setInsuranceNumber(e.target.value.toUpperCase());
+ clearFieldError("insuranceNumber");
+ }}
placeholder="A123456780"
maxLength={10}
autoCapitalize="characters"