diff --git a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json index aef1dd3..7fad2e8 100644 --- a/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json +++ b/.ideai/background-tasks/97b49ac2-8376-4aa3-8ea9-bf3ac81d0023.json @@ -8177,10 +8177,88 @@ "ticket_id": "8bf414e0-60f1-4fb8-87ff-76d618150fb0", "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" }, - "state": "running", + "state": "completed", "wakePolicy": "recordOnly", "createdAtMs": 1784277230713, - "updatedAtMs": 1784277230713, + "updatedAtMs": 1784277293596, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784277293596, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "**#74 est intégré dans `develop`. Working tree propre, rien en attente, aucun push effectué.**\n\n## Ce que j'ai commité\n\nUn seul commit restait à faire : `4de25d2 — chore(ideai): état runtime du ticket #74 (passage en QA)`. Il porte les 4 fichiers non commités (issue, carnet, index des tickets, accusés de complétion des rendez-vous headless). C'est de l'état runtime pur, donc isolé du code de feature selon ma convention. J'ai vérifié avec `--untracked-files=all` qu'aucun artefact de build ne traînait (`dist/`, `dist-web/`, `target/`, AppImage) — rien.\n\n## Ce que j'ai mergé\n\n`aec4293` — merge `--no-ff` de `feature/ticket74-web-transport-bundle` vers `develop`. Aucun conflit, `develop` n'avait pas bougé depuis la création de la branche (0 commit de retard), donc pas de rebase préalable nécessaire.\n\n**Un point mérite ton attention** : ce merge intègre **deux tickets**, pas un. La branche #74 était empilée sur `feature/ticket68-appimage-web-assets` et contenait `166adc3` (#68, packaging des assets web en ressource Tauri), qui n'était pas encore dans `develop`. C'était voulu et je l'assume : le packaging #68 servait précisément le bundle cassé que #74 corrige, il ne pouvait pas être déclaré vert tout seul. Les deux ferment ensemble. Si tu considérais #68 comme ayant sa propre porte QA à franchir séparément, dis-le moi — mais techniquement, le séparer était impossible sans conflit frontal sur les mêmes lignes de `tauri.conf.json`.\n\nJ'ai aussi supprimé `feature/ticket68-appimage-web-assets` : elle était un ancêtre strict de la branche #74, sans aucun commit propre. La garder n'apportait rien.\n\n## État final\n\n`develop` est sur `aec4293`, en avance de **34 commits sur `origin/develop`**, arbre propre. Les deux branches de feature (`ticket74-web-transport-bundle`, `ticket68-appimage-web-assets`) sont supprimées, leur travail vivant dans `develop`.\n\n## Ce qui reste en suspens\n\nLe **statut des tickets #68 et #74 est resté à `qa`** dans les métadonnées. Le code est mergé et validé live, mais la clôture formelle d'un ticket n'est pas ma décision — c'est Main qui arbitre. À faire passer en `done` de son côté.\n\nCôté remote, **les 34 commits d'avance sur `origin/develop` ne sont pas poussés**, conformément à mon périmètre strictement local. Un `git push origin develop` les publierait ; dis-moi si tu le veux et je m'en occupe. Enfin, `develop → main` (release) n'a pas été touché, ça reste une décision explicite de ta part.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "078855de-999f-40a8-97fc-0b5c1be2fe5c", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "c3e0d9d8-df36-4b00-b271-62d8aa78892c", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "c3e0d9d8-df36-4b00-b271-62d8aa78892c", + "ticket_id": "1a3657bd-1067-4a0c-96ab-28bb326a3e6c", + "conversation_id": "652e01c1-67a5-0913-0272-fc456de31f6d" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784277366856, + "updatedAtMs": 1784277379813, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784277379813, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Décision UX pour Ticket #75 :\n\n1. `inputMode`\nUtiliser `inputMode=\"text\"`.\n\nRaison : le code contient chiffres + lettres `A-F`; un pavé numérique rend la saisie partiellement impossible. Ne pas utiliser `numeric`, `decimal` ni `tel`.\n\n2. Placeholder / aide visuelle\nRemplacer le placeholder actuel par un exemple fidèle :\n\n`AB12-CD34`\n\nAjouter ou conserver une aide courte sous le champ si l’écran en a déjà une :\n\n`8 caractères : chiffres et lettres A-F`\n\nÉviter le mot “hexadécimal”. Il est exact techniquement, mais inutile côté utilisateur.\n\n3. Casse\nL’UI doit normaliser la saisie en majuscules avant affichage et avant envoi.\n\nComportement attendu :\n- si l’utilisateur tape `ab12cd34`, le champ affiche `AB12-CD34` ou `AB12CD34` selon le formatage existant retenu ;\n- la valeur envoyée au serveur est toujours `AB12CD34`, sans tiret ni espace ;\n- ne pas imposer à l’utilisateur de changer manuellement de casse.\n\nPérimètre frontend pur suffisant : normalisation côté UI avant submit. \nÀ noter comme robustesse future, mais hors périmètre demandé : le backend devrait idéalement comparer en normalisant aussi, car la tolérance ne doit pas dépendre uniquement du client.\n\n4. `autoCapitalize` / `autoComplete`\nMettre :\n\n- `autoCapitalize=\"characters\"`\n- `autoCorrect=\"off\"`\n- `autoComplete=\"one-time-code\"`\n- `spellCheck={false}` si le composant le permet\n\nCritères d’acceptation visuels/fonctionnels :\n- sur mobile, le clavier permet lettres et chiffres sans bascule manuelle obligatoire ;\n- le placeholder ne suggère plus un code numérique ;\n- une saisie minuscule aboutit à un code accepté par le serveur via normalisation frontend ;\n- l’aide reste courte, non technique, et ne prend pas plus d’une ligne sur mobile.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "fecf3304-b299-45f0-9ec6-0dff06d4a990", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "af7f86da-76bc-48e1-9900-71f45a624800", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "af7f86da-76bc-48e1-9900-71f45a624800", + "ticket_id": "e315464e-656f-4484-b444-18245987e85f", + "conversation_id": "09b15ec3-ce2f-0af2-2903-ef699df9de41" + }, + "state": "completed", + "wakePolicy": "recordOnly", + "createdAtMs": 1784277402194, + "updatedAtMs": 1784277521434, + "deadlineMs": null, + "result": { + "outcome": "success", + "finished_at_ms": 1784277521434, + "exit_code": null, + "summary": "Headless rendezvous completed with Final", + "stdout_tail": "Ticket #75 est corrigé et vert. Le champ demande maintenant le clavier texte et la valeur envoyée au serveur est normalisée en majuscules.\n\n## Ce que j'ai changé\n\n**`frontend/src/features/web/PairingScreen.tsx`** — les 5 points de la spec, tels quels :\n\n- `inputMode=\"numeric\"` → `inputMode=\"text\"`, et `autoCapitalize=\"off\"` → `\"characters\"`. Les autres attributs (`autoCorrect=\"off\"`, `autoComplete=\"one-time-code\"`, `spellCheck={false}`) sont conservés. J'ai remplacé le commentaire `#69` qui justifiait le pavé numérique par un commentaire `#75` expliquant pourquoi le clavier texte est requis.\n- Placeholder `p. ex. 4821-93` → `p. ex. AB12CD34`, sans séparateur comme tu l'as tranché.\n- L'aide passe par le `hint` de `Field`, qui la rend dans le `

` déjà câblé à `aria-describedby` — donc pas de `

` libre, et le lien accessibilité est automatique. Texte : « 8 caractères : chiffres et lettres A-F ».\n- Une fonction `normalize()` locale remplace le `trim()` : elle enlève tous les espaces (pas seulement en bord) et met en majuscules. C'est elle qui alimente `session.pair()`.\n- Le `disabled` du bouton submit passe de `code.trim().length === 0` à `normalize(code).length === 0`, donc une saisie de seuls espaces reste refusée, cohérente avec ce qui serait envoyé.\n\nPas de masque de saisie ni de formatage à la frappe, et l'écran ne parle toujours qu'à la `WebSession` injectée — aucun import `@tauri-apps` ajouté, le garde-fou `no-direct-invoke` n'est pas touché.\n\n**`frontend/src/features/web/PairingScreen.test.tsx`** (nouveau) — j'ai préféré un fichier dédié plutôt que d'alourdir `WebApp.test.tsx`, qui teste le routage et non le champ. Quatre tests, avec un `vi.spyOn(session, \"pair\")` sur une vraie `WebSession` à fetch injecté : le `inputMode` est bien `text`, le placeholder et l'aide sont fidèles, ` ab12cd34 ` part en `AB12CD34`, et le submit reste désactivé sur une saisie vide ou blanche.\n\n## Sortie de test réelle\n\n`cd frontend && npx vitest run` (suite complète) :\n\n```\n Test Files 88 passed (88)\n Tests 801 passed (801)\n Duration 16.78s\n```\n\n`npx tsc --noEmit` sort en 0. Les tests existants de `WebApp.test.tsx` passent sans modification : leur code `4821-93` traverse `normalize()` inchangé.\n\n## Dette à ouvrir, hors périmètre\n\nLa comparaison serveur (`crates/web-server/src/lib.rs:1612`) reste stricte sur la casse. Le fix d'aujourd'hui rend l'écran web tolérant, mais la tolérance vit côté client : tout autre client de l'API (curl, un futur client mobile natif, un test manuel) échouera encore sur `3f7a9c21`. Le backend devrait normaliser en majuscules à la comparaison, ce qui rendrait la normalisation UI redondante mais inoffensive. À toi de décider si ça mérite un ticket.", + "stderr_tail": null + }, + "completionDelivered": false + }, + { + "id": "cef21a7e-3eab-4bc5-a4d8-46af0cab4496", + "projectId": "97b49ac2-8376-4aa3-8ea9-bf3ac81d0023", + "ownerAgentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "kind": { + "kind": "headlessRendezvous", + "requester_agent_id": "a6ced819-b893-4213-b003-9e9dc79b9641", + "target_agent_id": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "ticket_id": "690948d6-4063-4d28-ba3c-a6b6545f03c0", + "conversation_id": "6bc594e8-a37c-0dbd-1de6-6e3b73002cb4" + }, + "state": "running", + "wakePolicy": "recordOnly", + "createdAtMs": 1784277549807, + "updatedAtMs": 1784277549807, "deadlineMs": null, "result": null, "completionDelivered": false diff --git a/.ideai/tickets/68/carnet.md b/.ideai/tickets/68/carnet.md index 9796242..6170a99 100644 --- a/.ideai/tickets/68/carnet.md +++ b/.ideai/tickets/68/carnet.md @@ -1,8 +1,8 @@ --- issueRef: "#68" -version: 7 +version: 8 updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1784238387151 +updatedAt: 1784277313706 --- # Ticket #68 — Serveur embarqué activable depuis le desktop : carnet de chantier diff --git a/.ideai/tickets/68/issue.md b/.ideai/tickets/68/issue.md index 821a523..6b45f1d 100644 --- a/.ideai/tickets/68/issue.md +++ b/.ideai/tickets/68/issue.md @@ -2,7 +2,7 @@ id: "13821b24-f566-402e-bc10-02ac6e8a5f7c" number: 68 title: "Ajouter dans l'app desktop, la possibilité d'activer le serveur" -status: "qa" +status: "closed" priority: "medium" sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6" links: [{"target":"#65","kind":"dependsOn"}] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"} createdBy: {"kind":"user"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784193318419 -updatedAt: 1784238387151 -version: 7 +updatedAt: 1784277313706 +version: 8 --- J'iamerais que depuis l'app desktop, on puisse quand même embarquer le serveur. Comme ça un utilisateur pourrait continuer son travail en cours en remote \ No newline at end of file diff --git a/.ideai/tickets/74/carnet.md b/.ideai/tickets/74/carnet.md index 1594ad1..f66e578 100644 --- a/.ideai/tickets/74/carnet.md +++ b/.ideai/tickets/74/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#74" -version: 2 +version: 3 updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} -updatedAt: 1784272247901 +updatedAt: 1784277308704 --- diff --git a/.ideai/tickets/74/issue.md b/.ideai/tickets/74/issue.md index cdf02ce..ad2f84c 100644 --- a/.ideai/tickets/74/issue.md +++ b/.ideai/tickets/74/issue.md @@ -2,7 +2,7 @@ id: "249bb0de-118f-4d6f-b0c4-1d4e2aadd83f" number: 74 title: "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined" -status: "qa" +status: "closed" priority: "high" sprint: null links: [{"target":"#68","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#13","kind":"relatesTo"}] @@ -10,8 +10,8 @@ agentRefs: [] createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} createdAt: 1784271086298 -updatedAt: 1784272247901 -version: 2 +updatedAt: 1784277308704 +version: 3 --- ## Symptôme (constaté live, exposition derrière reverse proxy) diff --git a/.ideai/tickets/75/carnet.md b/.ideai/tickets/75/carnet.md new file mode 100644 index 0000000..43bf53a --- /dev/null +++ b/.ideai/tickets/75/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#75" +version: 2 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784277530792 +--- diff --git a/.ideai/tickets/75/issue.md b/.ideai/tickets/75/issue.md new file mode 100644 index 0000000..f57fd4e --- /dev/null +++ b/.ideai/tickets/75/issue.md @@ -0,0 +1,48 @@ +--- +id: "d54a1f20-03e6-4925-982b-923a37331749" +number: 75 +title: "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres" +status: "qa" +priority: "medium" +sprint: null +links: [{"target":"#69","kind":"relatesTo"},{"target":"#74","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784277353320 +updatedAt: 1784277530792 +version: 2 +--- +## Symptôme (constaté live, téléphone) + +Sur l'écran d'appairage web (`PairingScreen`), le clavier virtuel qui s'ouvre est un pavé **numérique uniquement**, alors que le code d'appairage à saisir contient des **lettres**. Impossible de saisir le code sans changer de clavier à la main. + +## Cause racine (confirmée) + +Le code généré côté serveur est **hexadécimal, 8 caractères, majuscules** (`0-9A-F`) : + +```rust +// crates/web-server/src/lib.rs:2220 +fn new_pairing_code() -> String { + Uuid::new_v4().simple().to_string().chars().take(8) + .collect::().to_ascii_uppercase() +} +``` + +Or `frontend/src/features/web/PairingScreen.tsx:79` force `inputMode="numeric"` (introduit par #69 en supposant un code purement chiffré), et le placeholder `p. ex. 4821-93` (ligne 74) décrit un format qui n'existe pas. + +## Attendu + +- Le clavier mobile permet de saisir lettres **et** chiffres. +- Le placeholder reflète le format réel (hex 8 caractères majuscules). +- La saisie reste sans autocorrection ni capitalisation surprise ; le code étant en majuscules, la casse ne doit pas être un piège pour l'utilisateur. + +## Périmètre + +Frontend pur. Le format du code côté serveur n'est pas remis en cause par ce ticket. + +## DoD + +- Saisie possible au clavier mobile standard, lettres incluses. +- Placeholder cohérent avec `new_pairing_code()`. +- Test de non-régression sur le champ. \ No newline at end of file diff --git a/.ideai/tickets/76/carnet.md b/.ideai/tickets/76/carnet.md new file mode 100644 index 0000000..c70ae90 --- /dev/null +++ b/.ideai/tickets/76/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#76" +version: 1 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1784277540034 +--- diff --git a/.ideai/tickets/76/issue.md b/.ideai/tickets/76/issue.md new file mode 100644 index 0000000..01be580 --- /dev/null +++ b/.ideai/tickets/76/issue.md @@ -0,0 +1,40 @@ +--- +id: "c91f547f-96f0-4c9c-9fde-dbe7d2772dbc" +number: 76 +title: "Appairage : la comparaison du code est sensible à la casse côté serveur" +status: "open" +priority: "low" +sprint: null +links: [{"target":"#75","kind":"relatesTo"}] +agentRefs: [] +createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784277540034 +updatedAt: 1784277540034 +version: 1 +--- +## Constat + +`crates/web-server/src/lib.rs:1612` compare le code d'appairage reçu au code attendu par égalité stricte : + +```rust +if request.code != state.pairing_code() { … } +``` + +Or `new_pairing_code()` (ligne 2220) génère le code en majuscules. Un client qui envoie `3f7a9c21` au lieu de `3F7A9C21` est donc rejeté. + +## Pourquoi c'est une dette et pas un bug bloquant + +Le fix #75 rend l'écran web tolérant en normalisant la saisie en majuscules avant `POST /api/pair`. La tolérance vit donc **côté client** : tout autre client de l'API (curl, un futur client mobile natif, un test manuel) échouera encore sur une saisie minuscule, avec un message d'erreur qui n'explique pas pourquoi. + +## Attendu + +La comparaison serveur normalise la casse (et les espaces) avant égalité. La normalisation côté UI de #75 devient alors redondante mais inoffensive — pas besoin de la retirer. + +## Vigilance + +La comparaison doit rester en temps constant si elle l'est aujourd'hui : normaliser ne doit pas introduire de court-circuit exploitable en timing. À vérifier lors de l'implémentation. + +## Périmètre + +Backend pur, `crates/web-server`. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 1ca5d8b..4fa6919 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 75 + "nextNumber": 77 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 10b261a..1a94f21 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -707,13 +707,13 @@ "issueRef": "#68", "path": "68", "title": "Ajouter dans l'app desktop, la possibilité d'activer le serveur", - "status": "qa", + "status": "closed", "priority": "medium", "sprint": "028179b1-eaf4-41e9-9c1f-7c37125117e6", "assignedAgentIds": [ "a6ced819-b893-4213-b003-9e9dc79b9641" ], - "updatedAt": 1784238387151 + "updatedAt": 1784277313706 }, { "issueRef": "#69", @@ -773,11 +773,31 @@ "issueRef": "#74", "path": "74", "title": "Le serveur embarqué sert le bundle desktop (Tauri) au navigateur — __TAURI_INTERNALS__ undefined", - "status": "qa", + "status": "closed", "priority": "high", "sprint": null, "assignedAgentIds": [], - "updatedAt": 1784272247901 + "updatedAt": 1784277308704 + }, + { + "issueRef": "#75", + "path": "75", + "title": "Écran d'appairage web : clavier numérique sur mobile alors que le code contient des lettres", + "status": "qa", + "priority": "medium", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784277530792 + }, + { + "issueRef": "#76", + "path": "76", + "title": "Appairage : la comparaison du code est sensible à la casse côté serveur", + "status": "open", + "priority": "low", + "sprint": null, + "assignedAgentIds": [], + "updatedAt": 1784277540034 } ] } \ No newline at end of file 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) {

)} -