diff --git a/frontend/src/features/web/PairingScreen.test.tsx b/frontend/src/features/web/PairingScreen.test.tsx
new file mode 100644
index 0000000..4aab76a
--- /dev/null
+++ b/frontend/src/features/web/PairingScreen.test.tsx
@@ -0,0 +1,77 @@
+/**
+ * #75 — the pairing code is uppercase hex (`3F7A9C21`), not digits:
+ * - the field must summon the text keyboard, not the numeric keypad;
+ * - a lowercase entry must reach `session.pair()` uppercased and space-free,
+ * because the server compares the code strictly.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import { WebSession } from "@/adapters/http";
+import type { FetchLike } from "@/adapters/http/httpInvoker";
+import type { FlagStore } from "@/adapters/http/webSession";
+import { PairingScreen } from "./PairingScreen";
+
+function memStore(): FlagStore {
+ const map = new Map();
+ return {
+ getItem: (k) => map.get(k) ?? null,
+ setItem: (k, v) => void map.set(k, v),
+ removeItem: (k) => void map.delete(k),
+ };
+}
+
+const okFetch: FetchLike = async () => ({
+ ok: true,
+ status: 200,
+ json: async () => ({ ok: true }),
+ text: async () => "{}",
+});
+
+function setup() {
+ const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
+ const pair = vi.spyOn(session, "pair");
+ const onPaired = vi.fn();
+ render();
+ return { pair, onPaired };
+}
+
+describe("PairingScreen code field", () => {
+ it("asks for the text keyboard, not the numeric keypad", () => {
+ setup();
+ const field = screen.getByLabelText("Code d'appairage");
+
+ expect(field.getAttribute("inputmode")).toBe("text");
+ expect(field.getAttribute("autocapitalize")).toBe("characters");
+ expect(field.getAttribute("autocomplete")).toBe("one-time-code");
+ });
+
+ it("describes the code without lying about its shape", () => {
+ setup();
+ const field = screen.getByLabelText("Code d'appairage");
+
+ expect(field.getAttribute("placeholder")).toBe("p. ex. AB12CD34");
+ expect(screen.getByText("8 caractères : chiffres et lettres A-F")).toBeTruthy();
+ });
+
+ it("uppercases and strips spaces before pairing", async () => {
+ const { pair, onPaired } = setup();
+
+ fireEvent.change(screen.getByLabelText("Code d'appairage"), {
+ target: { value: " ab12cd34 " },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
+
+ await vi.waitFor(() => expect(onPaired).toHaveBeenCalled());
+ expect(pair).toHaveBeenCalledWith("AB12CD34");
+ });
+
+ it("keeps submit disabled for a blank code", () => {
+ setup();
+ const submit = screen.getByRole("button", { name: "Appairer" });
+ expect((submit as HTMLButtonElement).disabled).toBe(true);
+
+ fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: " " } });
+ expect((submit as HTMLButtonElement).disabled).toBe(true);
+ });
+});
diff --git a/frontend/src/features/web/PairingScreen.tsx b/frontend/src/features/web/PairingScreen.tsx
index 32bb704..89edf46 100644
--- a/frontend/src/features/web/PairingScreen.tsx
+++ b/frontend/src/features/web/PairingScreen.tsx
@@ -31,6 +31,14 @@ function describe(e: unknown): string {
return String(e);
}
+/**
+ * The server compares the code strictly against the uppercase hex it generated,
+ * so the UI uppercases (and drops stray spaces) before sending.
+ */
+function normalize(raw: string): string {
+ return raw.replace(/\s+/g, "").toUpperCase();
+}
+
export function PairingScreen({ session, onPaired }: PairingScreenProps) {
const [code, setCode] = useState("");
const [error, setError] = useState(null);
@@ -38,12 +46,12 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
async function submit(e: FormEvent): Promise {
e.preventDefault();
- const trimmed = code.trim();
- if (!trimmed || busy) return;
+ const normalized = normalize(code);
+ if (!normalized || busy) return;
setBusy(true);
setError(null);
try {
- await session.pair(trimmed);
+ await session.pair(normalized);
onPaired();
} catch (err) {
setError(describe(err));
@@ -64,20 +72,20 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
-
+
{({ id, describedBy }) => (
setCode(e.target.value)}
- placeholder="p. ex. 4821-93"
+ placeholder="p. ex. AB12CD34"
autoFocus
autoComplete="one-time-code"
- // #69 — phones: summon the numeric-ish keypad for a pairing code
- // and keep the OS from "helpfully" capitalising/correcting it.
- inputMode="numeric"
- autoCapitalize="off"
+ // #75 — the code mixes digits and letters, so phones need the
+ // text keyboard; uppercase it on the way in, never autocorrect.
+ inputMode="text"
+ autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
disabled={busy}
@@ -92,7 +100,7 @@ export function PairingScreen({ session, onPaired }: PairingScreenProps) {
)}
-