Révision UX : portrait stable, compte de points, menu, paysage « plateau = écran » #35

Manually merged
pierre merged 11 commits from revision-ux into main 2026-11-03 19:07:22 +00:00
3 changed files with 87 additions and 16 deletions
Showing only changes of commit 155a953c3b - Show all commits
+27 -6
View File
@@ -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<void> {
// 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<void>;
};
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.
+33 -2
View File
@@ -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.
+27 -8
View File
@@ -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