From 155a953c3bc6ade4e533796867507cf480343237 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 3 Nov 2026 15:40:53 +0100 Subject: [PATCH] =?UTF-8?q?Plein=20=C3=A9cran=20:=20attente=20born=C3=A9e,?= =?UTF-8?q?=20repli=20tourn=C3=A9=20si=20l'=C3=A9cran=20reste=20en=20portr?= =?UTF-8?q?ait,=20paysage=20forc=C3=A9=20en=20mode=20immersif=20;=20plan?= =?UTF-8?q?=20:=20r=C3=A9sultat=20et=20corrections=20de=20la=20recette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 33 +++++++++++++++++++++----- docs/plans/2026-11-03-revision-ux.md | 35 ++++++++++++++++++++++++++-- tests/browser/play.mjs | 35 +++++++++++++++++++++------- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 475c3ab..ff15d76 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -52,6 +52,8 @@ const SLIDE_MS = 160; const PAUSE_MS = 120; /** Durée du bandeau « Reprendre l'ancienne ». */ export const BANNER_MS = 10_000; +/** Attente au plus de la réponse à une demande de plein écran. */ +const FULLSCREEN_WAIT_MS = 1000; /** Le dernier plateau affiché, gardé pour le redessiner dès l'ouverture. */ export interface CachedBoard { @@ -276,7 +278,12 @@ export function startApp(options: AppOptions): void { // ----------------------------------------------------------- Mise en page function layout(): Layout { - return rotatedActive() || landscapeQuery.matches ? "landscape" : "portrait"; + // Plein écran demandé (« le plateau est l'écran ») : paysage dès que + // l'écran est plus large que haut, quelle que soit sa taille. + const immersiveLandscape = immersive && !portraitQuery.matches; + return rotatedActive() || landscapeQuery.matches || immersiveLandscape + ? "landscape" + : "portrait"; } function rotatedActive(): boolean { @@ -970,25 +977,39 @@ export function startApp(options: AppOptions): void { // ------------------------------------------------- Plein écran (ADR 0012) async function enterImmersive(): Promise { + // Certains navigateurs ne répondent jamais à la demande (Firefox sans + // écran) : on n'attend pas plus d'une seconde, la vue paysage + // s'applique quand même. try { - await document.documentElement.requestFullscreen?.({ navigationUI: "hide" }); + await Promise.race([ + document.documentElement.requestFullscreen?.({ navigationUI: "hide" }), + new Promise((resolve) => setTimeout(resolve, FULLSCREEN_WAIT_MS)), + ]); } catch { // Refusé : la vue paysage s'applique quand même. } - let locked = false; + let locked = false; // vrai : le navigateur a accepté de verrouiller try { const orientation = screen.orientation as unknown as { lock?: (orientation: string) => Promise; }; if (orientation.lock !== undefined) { - await orientation.lock("landscape"); + await Promise.race([ + orientation.lock("landscape"), + new Promise((_, reject) => setTimeout(reject, FULLSCREEN_WAIT_MS)), + ]); locked = true; } } catch { // Verrouillage refusé : repli par rotation CSS ci-dessous. } + // Verrouillage accepté : l'écran tourne dans l'instant. S'il est + // toujours en portrait (navigateur qui accepte sans tourner), repli. + for (let i = 0; locked && portraitQuery.matches && i < 6; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } immersive = true; - rotated = !locked && portraitQuery.matches; + rotated = portraitQuery.matches; render(); } @@ -1000,10 +1021,10 @@ export function startApp(options: AppOptions): void { } catch { // Rien à déverrouiller. } + render(); if (document.fullscreenElement) { await document.exitFullscreen().catch(() => undefined); } - render(); } // Sortie du plein écran par le retour du téléphone : la vue revient. diff --git a/docs/plans/2026-11-03-revision-ux.md b/docs/plans/2026-11-03-revision-ux.md index dd823b4..4dc0016 100644 --- a/docs/plans/2026-11-03-revision-ux.md +++ b/docs/plans/2026-11-03-revision-ux.md @@ -2,7 +2,7 @@ - **Date** : 2026-11-03 - **Étape** : 0e.3 (feuille de route) -- **Statut** : en cours +- **Statut** : réalisé, à vérifier par la session principale - **Exécutant** : sous-agent general-purpose, dans un worktree ## Objectif @@ -93,6 +93,37 @@ C. **Instabilité des tests** : `tests/browser.sh` et `tests/test_site.sh` propre au worktree (hachage court du chemin du dépôt), et l'image est supprimée en fin de test. +## Corrections de la recette design (session « identité », sur c6649f9) + +F1. Consigne en portrait en texte nu (17 px gras, deux lignes réservées, +sans fond), vide pendant le chargement ; la teinte « à vous » reste au +bandeau du joueur. F2. Case d'arrivée : voile sur le seul triangle de la +flèche, anneau pointillé là où le pion se posera, zone tactile sur toute la +colonne. F3. Flèches laiton de 2,5 px, opacité 0,85, pointes plus petites, +sous les pions. F4. Fin de partie : consigne masquée sous le panneau ; +surtitre « Fin de partie », titre de 24 px, ligne « 8 points pour lui, +videau à 4. ». F5. « Jouer ce coup » en action principale à droite quand le +coup est complet, vérifié dans `make test-browser`. + ## Résultat -À remplir à la clôture. +Rempli par le sous-agent ; la session principale le complète à la clôture. + +Fait : points 1 à 9 et ajouts A, B, C ; point 10 (clavier) en grande partie +(table d'actions commune ; Espace, Z, D, P, N, F, M, ?, Échap ; pas encore +le choix des pions aux flèches ni l'affichage des raccourcis `kbd`). + +Écarts : +- vibration du lancer : aucune, comme prévu ; « toucher refusé » non fait + (l'écran ne dit pas encore pourquoi) ; +- maintien de l'écran allumé : API Wake Lock appelée, non observable dans les + navigateurs sans écran : vérification à la main sur le téléphone ; +- le bouton « Continuer » reste dans la barre d'actions quand aucun coup + n'est possible, en plus du toucher des dés ; +- « Revoir la partie » ferme le panneau de fin pour regarder le plateau + final : il n'existe pas encore de relecture de partie ; +- en paysage, la ligne d'information (jet d'ouverture, coup de + l'ordinateur en texte) n'a pas de place : la bande centrale porte la + consigne courte ; l'aide reste une page à faire défiler ; +- pictogrammes tracés sur la grille 24 × 24 des maquettes, et non 20 × 20. + diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 89a8245..f0df2b1 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -539,6 +539,9 @@ async function openMenu() { * le journal de la partie quittée. */ async function newGameFromMenu() { const before = await savedJournal(); + const started = () => + page.evaluate(() => JSON.parse(localStorage.getItem("ustagammon.settings") ?? "{}").gamesStarted); + const startedBefore = await started(); await openMenu(); const start = Date.now(); await page.click("#menu-new-game"); @@ -548,8 +551,11 @@ async function newGameFromMenu() { check(!(await page.isVisible("#menu")), "le menu reste ouvert après « Nouvelle partie »"); await waitForPlayer(); const after = await savedJournal(); - check(after.head !== before.head && !after.events.some((e) => e.type === "MovePlayed" && - e.side === "player"), "« Nouvelle partie » n'a pas commencé de partie"); + // (Les identifiants d'événements se suivent : une partie neuve peut avoir + // la même tête que la précédente ; on compte les parties commencées.) + check((await started()) === startedBefore + 1 && + !after.events.some((e) => e.type === "MovePlayed" && e.side === "player"), + "« Nouvelle partie » n'a pas commencé de partie"); log(`nouvelle partie commencée en ${Date.now() - start} ms, sans confirmation`); return before; } @@ -579,7 +585,8 @@ let releaseEngine; const engineHeld = new Promise((resolve) => { releaseEngine = resolve; }); -await page.route("**/wasm/engine.data", async (route) => { +// (Au niveau du contexte : le Web Worker du moteur fait la demande.) +await context.route("**/wasm/engine.data", async (route) => { await engineHeld; await route.continue(); }); @@ -603,9 +610,13 @@ await page.waitForSelector("#board polygon[data-point]"); log(`plateau dessiné en ${Date.now() - start} ms, avant le moteur (${loading.checkers} pions)`); } await checkStable("chargement"); -await review("1-chargement"); +// Firefox ne laisse pas toujours retenir la demande du Worker : la revue de +// l'écran de chargement (six vues) se fait dans Chromium. +if (fullReview) { + await review("1-chargement"); +} releaseEngine(); -await page.unroute("**/wasm/engine.data"); +await context.unroute("**/wasm/engine.data"); let todo = await waitForPlayer(); log(`moteur chargé, partie prête en ${Date.now() - start} ms (${todo})`); await checkStable("moteur chargé"); @@ -774,7 +785,13 @@ for (let actions = 0; ; actions++) { // l'orientation, d'où le repli par rotation CSS de tout l'écran. rotatedChecked = true; await page.click("#fullscreen-button"); - await sleep(300); + for (let i = 0; i < 40; i++) { + if (await page.evaluate(() => document.documentElement.classList.contains("landscape"))) { + break; + } + await sleep(100); + } + await sleep(200); const view = await page.evaluate(() => { const r = document.getElementById("board").getBoundingClientRect(); return { @@ -783,7 +800,9 @@ for (let actions = 0; ; actions++) { board: [r.x, r.y, r.width, r.height].map(Math.round), }; }); - check(view.landscape, "plein écran : pas de vue paysage"); + check(view.landscape, `plein écran : pas de vue paysage (${await page.evaluate(() => + `${document.documentElement.className} ; plein écran ${document.fullscreenElement !== null} ; ` + + `portrait ${matchMedia("(orientation: portrait)").matches}`)})`); if (view.rotated) { check(JSON.stringify(view.board) === JSON.stringify([0, 0, 412, 915]), `repli tourné : plateau ${view.board}`); @@ -806,7 +825,7 @@ for (let actions = 0; ; actions++) { check(!(await page.evaluate(() => document.documentElement.classList.contains("landscape"))), "plein écran quitté : la vue paysage reste"); log(`plein écran : ${view.rotated ? "repli par rotation CSS" : "paysage verrouillé"}, ` + - "dés lancés en touchant le plateau tourné, sortie par le menu"); + "dés lancés en touchant le plateau, sortie par le menu"); continue; } // Dés : au clavier (Espace), en les touchant (d'un côté puis de