diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts
index 0a7c638ec..10e0598d7 100644
--- a/src/__tests__/unit-preference-display-guard.test.ts
+++ b/src/__tests__/unit-preference-display-guard.test.ts
@@ -46,6 +46,27 @@ const TARGET_SURFACES = [
join(SRC, "components", "settings", "thresholds-editor-section.tsx"),
];
+/**
+ * v1.32.30 — the profile height surfaces. Height is not a
+ * `MeasurementType`, so `display-transform.ts` never reached it and it
+ * stayed in centimetres for two releases after everything else moved.
+ * These three are the coupled entry unit: the two forms that write
+ * `User.heightCm` and the control they share. A partial wire here is
+ * the same failure the target block guards against — a converted field
+ * over a canonical save silently rewrites the stored height.
+ */
+const HEIGHT_SURFACES = [
+ join(SRC, "components", "onboarding", "baseline-form.tsx"),
+ join(SRC, "components", "settings", "account-section", "index.tsx"),
+];
+
+const HEIGHT_CONTROL = join(
+ SRC,
+ "components",
+ "profile",
+ "height-field-control.tsx",
+);
+
/**
* Swept surfaces: the dashboard client, every insights sub-page, and the
* target/threshold surfaces.
@@ -124,6 +145,40 @@ describe("unit-preference display guard", () => {
expect(offenders).toEqual([]);
});
+ it("every height entry surface resolves through the profile adapter", () => {
+ // Same tripwire as the target block: drop the hook or the adapter
+ // 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");
+ if (!src.includes("useUnitDisplay()")) {
+ offenders.push(`${file}: no preference hook`);
+ }
+ if (!src.includes("resolveHeightUnitAdapter")) {
+ offenders.push(`${file}: no height adapter`);
+ }
+ if (!src.includes("HeightFieldControl")) {
+ offenders.push(`${file}: 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`);
+ }
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ it("the height control announces its units from the bundle", () => {
+ const src = readFileSync(HEIGHT_CONTROL, "utf8");
+ // A quoted "cm" / "ft" / "in" here is a hardcoded unit label; every
+ // unit string on this control resolves through `t(...)`.
+ expect(src).not.toMatch(/["'](cm|ft|in)["']/);
+ expect(src).toContain('t("common.feet")');
+ expect(src).toContain('t("common.inches")');
+ });
+
it("every transformed type carries both a metric and an imperial branch", () => {
for (const type of TRANSFORMED_TYPES) {
const metric = getDisplayTransform(type, "metric");
diff --git a/src/components/profile/__tests__/height-entry-surfaces.test.tsx b/src/components/profile/__tests__/height-entry-surfaces.test.tsx
new file mode 100644
index 000000000..97af6e93a
--- /dev/null
+++ b/src/components/profile/__tests__/height-entry-surfaces.test.tsx
@@ -0,0 +1,133 @@
+/**
+ * v1.32.30 — the two surfaces that ENTER a height: the onboarding
+ * baseline step and the Settings profile card.
+ *
+ * Vitest runs in the Node environment here (no jsdom), so this asserts
+ * the SSR markup — which is exactly where the failure this release
+ * closes lived: an imperial account being handed a box labelled "cm".
+ *
+ * Mutation check: swap either surface's label back to the unconditional
+ * `t("settings.height")` / `t("onboarding.baseline.heightLabel")` and
+ * the "(ft, in)" assertions go red; drop the adapter and the feet +
+ * inches inputs disappear with them.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+let unitPreference: "metric" | "imperial" = "metric";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+ usePathname: () => "/settings/account",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+vi.mock("sonner", () => ({
+ toast: { error: vi.fn(), success: vi.fn() },
+}));
+
+vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: {
+ id: "u1",
+ username: "testuser",
+ email: "user@example.com",
+ heightCm: 180.34,
+ dateOfBirth: "1990-01-01",
+ gender: "MALE",
+ role: "USER",
+ unitPreference,
+ },
+ isAuthenticated: true,
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { BaselineForm } from "@/components/onboarding/baseline-form";
+import { AccountSection } from "@/components/settings/account-section";
+
+function renderUnder(
+ preference: "metric" | "imperial",
+ node: React.ReactElement,
+): string {
+ unitPreference = preference;
+ try {
+ return renderToStaticMarkup(
+ {node},
+ );
+ } finally {
+ unitPreference = "metric";
+ }
+}
+
+const SURFACES = [
+ {
+ name: "onboarding baseline step",
+ node: ,
+ firstInputId: "ob-baseline-height",
+ metricLabel: "Height (cm)",
+ },
+ {
+ name: "Settings profile card",
+ node: ,
+ firstInputId: "height",
+ metricLabel: "Height (cm)",
+ },
+] as const;
+
+describe.each(SURFACES)("$name — height entry", (surface) => {
+ it("keeps the single centimetres box for a metric account", () => {
+ const html = renderUnder("metric", surface.node);
+ expect(html).toContain(surface.metricLabel);
+ expect(html).not.toContain("Height (ft, in)");
+ expect(html).toContain(`id="${surface.firstInputId}"`);
+ expect(html).not.toContain(`id="${surface.firstInputId}-inches"`);
+ // The canonical guardrails, unconverted, on the height box itself.
+ expect(html).toMatch(
+ new RegExp(
+ `id="${surface.firstInputId}"[^>]*min="50"[^>]*max="300"`,
+ "u",
+ ),
+ );
+ });
+
+ it("shows feet + inches for an imperial account", () => {
+ const html = renderUnder("imperial", surface.node);
+ expect(html).toContain("Height (ft, in)");
+ expect(html).not.toContain(surface.metricLabel);
+ expect(html).toContain(`id="${surface.firstInputId}"`);
+ expect(html).toContain(`id="${surface.firstInputId}-inches"`);
+ expect(html).toContain('aria-label="Height in feet"');
+ expect(html).toContain('aria-label="Height in inches"');
+ // The unit suffixes come from the bundle, not from a hardcoded
+ // literal in the surface.
+ expect(html).toContain(">ft");
+ expect(html).toContain(">in");
+ // The canonical 50/300 centimetre guardrails must not survive onto
+ // a feet field.
+ expect(html).not.toContain('max="300"');
+ });
+
+ it("seeds the imperial fields from the stored centimetres", () => {
+ const html = renderUnder("imperial", surface.node);
+ // 180.34 cm is exactly 71 in.
+ if (surface.firstInputId === "height") {
+ expect(html).toMatch(/id="height"[^>]*value="5"/u);
+ expect(html).toMatch(/id="height-inches"[^>]*value="11"/u);
+ } else {
+ // The onboarding step starts blank by design — it is a fresh
+ // profile, not an edit of an existing one.
+ expect(html).toMatch(/id="ob-baseline-height"[^>]*value=""/u);
+ }
+ });
+});
diff --git a/src/lib/profile/__tests__/height-unit-display.test.ts b/src/lib/profile/__tests__/height-unit-display.test.ts
new file mode 100644
index 000000000..53d3cbeab
--- /dev/null
+++ b/src/lib/profile/__tests__/height-unit-display.test.ts
@@ -0,0 +1,219 @@
+/**
+ * v1.32.30 — the height entry adapter.
+ *
+ * Two properties carry the whole feature and both are swept, not
+ * sampled:
+ *
+ * 1. Round-trip stability. Every feet + inches pair the imperial
+ * fields can offer must survive save-then-reopen unchanged, or a
+ * user's height creeps by a fraction on every visit to Settings.
+ * 2. Inward-rounded bounds. Every value inside the client's own
+ * limits must invert to a centimetre value the server's 50-300
+ * Zod bound accepts, or an imperial user is handed a field that
+ * 422s.
+ *
+ * The metric branch is asserted with `toBe` (identity, no rounding
+ * drift), because "we quietly re-derived every metric account's stored
+ * height" is the expensive way to get this wrong.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ EMPTY_HEIGHT_DRAFT,
+ resolveHeightUnitAdapter,
+} from "@/lib/profile/height-unit-display";
+
+/** The canonical window `src/lib/validations/auth.ts` enforces. */
+const MIN_CM = 50;
+const MAX_CM = 300;
+
+const metric = resolveHeightUnitAdapter("metric");
+const imperial = resolveHeightUnitAdapter("imperial");
+
+describe("height unit adapter — metric branch", () => {
+ it("renders a single centimetres field, not the feet + inches pair", () => {
+ expect(metric.usesFeetInches).toBe(false);
+ expect(metric.preference).toBe("metric");
+ });
+
+ it("seeds the draft as the exact stored number, unrounded", () => {
+ for (const cm of [50, 160, 175.5, 180, 180.34, 199.99, 300]) {
+ expect(metric.toDraft(cm)).toEqual({
+ cm: String(cm),
+ feet: "",
+ inches: "",
+ });
+ }
+ });
+
+ it("returns the typed centimetres untouched (identity, no drift)", () => {
+ for (const typed of ["50", "160", "175.5", "180", "180.34", "300"]) {
+ expect(metric.toCanonicalCm({ ...EMPTY_HEIGHT_DRAFT, cm: typed })).toBe(
+ Number.parseFloat(typed),
+ );
+ }
+ });
+
+ it("round-trips every stored centimetre value byte-for-byte", () => {
+ for (let cm = MIN_CM; cm <= MAX_CM; cm += 0.5) {
+ expect(metric.toCanonicalCm(metric.toDraft(cm))).toBe(cm);
+ }
+ });
+
+ it("reads a blank or unparseable field as no height", () => {
+ expect(metric.toCanonicalCm(EMPTY_HEIGHT_DRAFT)).toBeNull();
+ expect(
+ metric.toCanonicalCm({ cm: " ", feet: "", inches: "" }),
+ ).toBeNull();
+ expect(
+ metric.toCanonicalCm({ cm: "abc", feet: "", inches: "" }),
+ ).toBeNull();
+ expect(metric.toDraft(null)).toEqual(EMPTY_HEIGHT_DRAFT);
+ expect(metric.toDraft(undefined)).toEqual(EMPTY_HEIGHT_DRAFT);
+ });
+
+ it("offers the canonical guardrails unconverted", () => {
+ expect(metric.bounds.cm).toEqual({ min: MIN_CM, max: MAX_CM });
+ });
+});
+
+describe("height unit adapter — imperial branch", () => {
+ it("renders the two-part feet + inches row", () => {
+ expect(imperial.usesFeetInches).toBe(true);
+ expect(imperial.preference).toBe("imperial");
+ });
+
+ it("converts the worked example both ways", () => {
+ const cm = imperial.toCanonicalCm({ cm: "", feet: "5", inches: "11" });
+ expect(cm).toBe(180.34);
+ expect(imperial.toDraft(cm)).toEqual({ cm: "", feet: "5", inches: "11" });
+ });
+
+ it("round-trips EVERY feet + inches pair the fields can offer", () => {
+ const { feet, inches } = imperial.bounds;
+ let checked = 0;
+ for (let ft = feet.min; ft <= feet.max; ft += 1) {
+ for (let inch = inches.min; inch <= inches.max; inch += 1) {
+ const draft = { cm: "", feet: String(ft), inches: String(inch) };
+ const cm = imperial.toCanonicalCm(draft);
+ expect(cm).not.toBeNull();
+ expect(imperial.toDraft(cm)).toEqual(draft);
+ checked += 1;
+ }
+ }
+ // A silently empty sweep is a green void.
+ expect(checked).toBeGreaterThan(60);
+ });
+
+ it("round-trips every whole-inch height across the plausible range", () => {
+ // Independent of the offered rectangle: the full canonical window
+ // expressed in whole inches, which is what a height synced from a
+ // wearable can land on.
+ for (let totalInches = 20; totalInches <= 118; totalInches += 1) {
+ const draft = {
+ cm: "",
+ feet: String(Math.floor(totalInches / 12)),
+ inches: String(totalInches % 12),
+ };
+ const cm = imperial.toCanonicalCm(draft);
+ expect(cm).not.toBeNull();
+ expect(cm).toBeGreaterThanOrEqual(MIN_CM);
+ expect(cm).toBeLessThanOrEqual(MAX_CM);
+ expect(imperial.toDraft(cm)).toEqual(draft);
+ }
+ });
+
+ it("keeps every value inside the offered limits acceptable to the server", () => {
+ const { feet, inches } = imperial.bounds;
+ const corners = [
+ { feet: feet.min, inches: inches.min },
+ { feet: feet.min, inches: inches.max },
+ { feet: feet.max, inches: inches.min },
+ { feet: feet.max, inches: inches.max },
+ ];
+ for (const corner of corners) {
+ const cm = imperial.toCanonicalCm({
+ cm: "",
+ feet: String(corner.feet),
+ inches: String(corner.inches),
+ });
+ expect(cm).not.toBeNull();
+ expect(cm).toBeGreaterThanOrEqual(MIN_CM);
+ expect(cm).toBeLessThanOrEqual(MAX_CM);
+ }
+ });
+
+ it("rounds the offered window INWARD, never outward", () => {
+ // The naive rounding: 300 cm is 118.11 in, so a feet ceiling of 9
+ // would let 9 ft 11 in (119 in = 302.26 cm) through the client and
+ // straight into a server 422.
+ const { feet, inches } = imperial.bounds;
+ expect(inches).toEqual({ min: 0, max: 11 });
+ const justOverTheCeiling = imperial.toCanonicalCm({
+ cm: "",
+ feet: String(feet.max + 1),
+ inches: "11",
+ });
+ expect(justOverTheCeiling).toBeGreaterThan(MAX_CM);
+ });
+
+ it("rejects a genuinely out-of-range height as out of range", () => {
+ const tooTall = imperial.toCanonicalCm({
+ cm: "",
+ feet: "12",
+ inches: "0",
+ });
+ expect(tooTall).toBe(365.76);
+ expect(tooTall).toBeGreaterThan(MAX_CM);
+ const tooShort = imperial.toCanonicalCm({ cm: "", feet: "1", inches: "0" });
+ expect(tooShort).toBeLessThan(MIN_CM);
+ });
+
+ it("treats an untouched inches box as zero, not as no height", () => {
+ expect(imperial.toCanonicalCm({ cm: "", feet: "6", inches: "" })).toBe(
+ 182.88,
+ );
+ expect(imperial.toCanonicalCm({ cm: "", feet: "", inches: "8" })).toBe(
+ 20.32,
+ );
+ });
+
+ it("reads a blank or unparseable pair as no height", () => {
+ expect(imperial.toCanonicalCm(EMPTY_HEIGHT_DRAFT)).toBeNull();
+ expect(
+ imperial.toCanonicalCm({ cm: "", feet: " ", inches: " " }),
+ ).toBeNull();
+ expect(
+ imperial.toCanonicalCm({ cm: "", feet: "abc", inches: "3" }),
+ ).toBeNull();
+ expect(imperial.toDraft(null)).toEqual(EMPTY_HEIGHT_DRAFT);
+ });
+
+ it("quantises a fractional inch so the round-trip still holds", () => {
+ const cm = imperial.toCanonicalCm({ cm: "", feet: "5", inches: "11.5" });
+ expect(cm).toBe(182.88); // 72 in
+ expect(imperial.toDraft(cm)).toEqual({ cm: "", feet: "6", inches: "0" });
+ expect(imperial.toCanonicalCm(imperial.toDraft(cm))).toBe(cm);
+ });
+
+ it("snaps a centimetre-entered height to the nearest whole inch", () => {
+ // A metric account that flips to imperial: 180 cm reads as 5 ft 11
+ // in, and the next save re-canonicalises to 180.34. One snap, then
+ // stable forever.
+ expect(imperial.toDraft(180)).toEqual({ cm: "", feet: "5", inches: "11" });
+ const resaved = imperial.toCanonicalCm(imperial.toDraft(180));
+ expect(resaved).toBe(180.34);
+ expect(imperial.toDraft(resaved)).toEqual({
+ cm: "",
+ feet: "5",
+ inches: "11",
+ });
+ });
+});
+
+describe("height unit adapter — resolution", () => {
+ it("falls back to the metric branch for anything but imperial", () => {
+ expect(resolveHeightUnitAdapter("metric")).toBe(metric);
+ expect(resolveHeightUnitAdapter("imperial")).toBe(imperial);
+ });
+});