From 86c0ebfc367b95abbb67cc0a71e75550f9f1e4ee Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Tue, 10 Nov 2026 00:10:15 +0100 Subject: [PATCH] =?UTF-8?q?Recette=20design=20:=20tutoriel=20en=20paysage,?= =?UTF-8?q?=20sens=20du=20jeu,=20bandeau=20de=20mise=20=C3=A0=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sens du jeu (étape 2) : le tracé passe sous les pions et sous les numéros des flèches ; liseré ivoire de 8 px, points de 4 px à l'écran. - Tutoriel en paysage : plateau à gauche sur toute la hauteur (géométrie du jeu en paysage, colonne gauche du cadre), colonne à droite avec la barre de progression, le titre, le texte, la consigne et les boutons ; seul le texte peut défiler. Tient en 915x412 et 844x320. - Recette automatique étendue au tutoriel : numéros d'au moins 12 px, page sans défilement et boutons en vue en paysage, captures en 844x320. - Boutons du videau « Prendre » et « Passer », lien « Quitter ». - Bandeau « Nouvelle version prête. » en bas, au-dessus de la barre d'actions, seulement entre deux tours (betweenTurns, testé). - Test des adresses : le lien Aide est lu au moment du toucher, la carte du terme ouvert est mesurée sous la barre de l'aide. Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 16 +- app/src/ui/board-view.ts | 1 + app/src/ui/home-texts.ts | 10 +- app/src/ui/tutorial-view.ts | 52 ++++-- app/src/ui/updates.ts | 29 ++-- app/tests/steps/regler-l-application.steps.ts | 25 ++- docs/scenarios/regler-l-application.feature | 11 +- tests/browser/accueil.mjs | 148 ++++++++++++++++-- tests/browser/play.mjs | 12 +- web/app.css | 135 ++++++++++++++-- web/index.html | 3 +- 11 files changed, 367 insertions(+), 75 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 7b902d8..0733953 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -64,7 +64,7 @@ import { isFirstLaunch, longPrompts, nextTheme, parseSettings, type Settings } f import { type PromptText, TEXTS } from "./texts.js"; import { seenAfter } from "./tutorial-nav.js"; import { createTutorialView, type TutorialAccess } from "./tutorial-view.js"; -import { type Updates, updateBannerShown } from "./updates.js"; +import { betweenTurns, type Updates, updateBannerShown } from "./updates.js"; /** Glissement d'un pion de l'ordinateur, et pause entre deux pions * (interaction.md : on suit le coup sans l'attendre). */ @@ -718,18 +718,17 @@ export function startApp(options: AppOptions): void { ui.helpButton.href = helpLink; ui.menuHelp.href = helpLink; ui.menuInstall.hidden = !installMenuShown(settings, installer.installed); - const moveInProgress = - busy || (game !== null && game.pendingHalfMoves.length > 0 && !ui.game.hidden); + // Le bandeau de mise à jour attend la fin du tour, même hors de + // l'écran de jeu : la partie enregistrée reprend alors sans perte. + const quiet = + !busy && + (game === null || betweenTurns(game.state.phase, game.pendingHalfMoves.length)); if (updateMessage !== null) { ui.updateBanner.hidden = false; ui.updateText.textContent = updateMessage; ui.updateApply.hidden = true; } else { - const shown = updateBannerShown( - updates?.available ?? false, - moveInProgress, - !ui.banner.hidden, - ); + const shown = updateBannerShown(updates?.available ?? false, quiet, !ui.banner.hidden); ui.updateBanner.hidden = !shown; ui.updateText.textContent = HOME_TEXTS.updateAvailable; ui.updateApply.hidden = false; @@ -1455,6 +1454,7 @@ export function startApp(options: AppOptions): void { }, placed: () => haptic("checkerPlaced"), reducedMotion, + landscape: () => layout() === "landscape", replace: (hash) => navigation.replace(hash), }); diff --git a/app/src/ui/board-view.ts b/app/src/ui/board-view.ts index 15a08ec..e2efcc2 100644 --- a/app/src/ui/board-view.ts +++ b/app/src/ui/board-view.ts @@ -166,6 +166,7 @@ export function renderBoard( // Les pions, dessinés à part puis posés par-dessus les flèches du coup // de l'ordinateur : une flèche ne cache jamais un pion. const pieces = document.createElementNS(SVG_NS, "g"); + pieces.setAttribute("class", "pieces"); for (const point of board.points) { drawPoint(svg, pieces, g, hits, board, point.number, decor); } diff --git a/app/src/ui/home-texts.ts b/app/src/ui/home-texts.ts index 622635f..b662d27 100644 --- a/app/src/ui/home-texts.ts +++ b/app/src/ui/home-texts.ts @@ -68,7 +68,7 @@ export const HOME_TEXTS = { installed: "Ustagammon est installé : ouvrez-le depuis son icône.", // Mise à jour - updateAvailable: "Nouvelle version disponible.", + updateAvailable: "Nouvelle version prête.", updateApply: "Mettre à jour", updateCheck: "Rechercher une mise à jour", updateChecking: "Recherche d'une mise à jour…", @@ -80,8 +80,8 @@ export const HOME_TEXTS = { tutorialMenu: "Les règles (tutoriel)", tutorialProgress: "Étapes du tutoriel", tutorialStepLink: (n: number, title: string) => `Étape ${n} : ${title}`, - tutorialSkip: "Passer", - tutorialSkipLabel: "Passer le tutoriel", + tutorialSkip: "Quitter", + tutorialSkipLabel: "Quitter le tutoriel", tutorialOverline: (n: number, total: number, practice: boolean) => `Les règles · ${n} sur ${total}${practice ? " · à vous" : ""}`, tutorialEndOverline: "Les règles · fin", @@ -90,8 +90,8 @@ export const HOME_TEXTS = { next: "Continuer", replay: "Revoir", replayLabel: "Revoir le coup", - take: "Je prends", - drop: "Je passe", + take: "Prendre", + drop: "Passer", tutorialLoading: "Chargement du tutoriel…", tutorialFailed: "Le tutoriel n'a pas pu se charger.", pathLabel: "Le sens du jeu de vos pions, en pointillés", diff --git a/app/src/ui/tutorial-view.ts b/app/src/ui/tutorial-view.ts index 815734d..7c6b2c1 100644 --- a/app/src/ui/tutorial-view.ts +++ b/app/src/ui/tutorial-view.ts @@ -95,6 +95,8 @@ export interface TutorialViewOptions { /** Un geste juste : la vibration « pion posé ». */ readonly placed: () => void; readonly reducedMotion: MediaQueryList; + /** L'écran est-il en paysage (plateau à gauche, texte à droite) ? */ + readonly landscape: () => boolean; /** L'adresse a désigné un écran qui n'existe pas : la remplacer. */ readonly replace: (hash: string) => void; } @@ -148,8 +150,11 @@ export function fillParagraph(target: HTMLElement, paragraph: Paragraph): void { const SVG_NS = "http://www.w3.org/2000/svg"; /** Le sens du jeu des pions du joueur, en pointillés : du haut à droite - * (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. */ -function drawPath(svg: SVGSVGElement, g: Geometry): void { + * (flèche 24) vers la gauche, puis en bas jusqu'à la flèche 1 et la sortie. + * Tracé sous les pions et sous les numéros des flèches, qu'il ne cache + * jamais. pxPerUnit : pixels à l'écran par unité du dessin (le liseré + * ivoire fait 8 px, les points 4 px, quelle que soit la taille). */ +function drawPath(svg: SVGSVGElement, g: Geometry, pxPerUnit: number): void { const center = (column: number) => columnLeft(g, column) + g.pointWidth / 2; const top = g.height * 0.3; const bottom = g.height * 0.7; @@ -164,9 +169,10 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void { group.setAttribute("class", "tutorial-path"); group.setAttribute("role", "img"); group.setAttribute("aria-label", HOME_TEXTS.pathLabel); - const width = g.pointWidth * 0.22; + const px = (n: number) => n / pxPerUnit; + const width = px(4); for (const [className, stroke] of [ - ["path-edge", width * 2.4], + ["path-edge", px(8)], ["path-dots", width], ] as const) { const path = document.createElementNS(SVG_NS, "path"); @@ -174,21 +180,26 @@ function drawPath(svg: SVGSVGElement, g: Geometry): void { path.setAttribute("d", d); path.setAttribute("stroke-width", String(stroke)); if (className === "path-dots") { - path.setAttribute("stroke-dasharray", `0.1 ${width * 2.6}`); + path.setAttribute("stroke-dasharray", `0.1 ${px(10)}`); } group.append(path); } const head = document.createElementNS(SVG_NS, "path"); head.setAttribute("class", "path-head"); - const size = width * 2.2; + const size = px(7); head.setAttribute( "d", `M ${end - size} ${bottom - size} L ${end} ${bottom} L ${end - size} ${bottom + size}`, ); - head.setAttribute("stroke-width", String(width * 1.2)); + head.setAttribute("stroke-width", String(px(3))); group.append(head); - // Sous les zones à toucher, toujours dessinées en dernier. - svg.insertBefore(group, svg.lastChild); + // Sous les pions, et sous les numéros des flèches, remontés au-dessus + // du tracé ; les zones à toucher restent devant tout. + const pieces = svg.querySelector(":scope > g.pieces"); + svg.insertBefore(group, pieces); + for (const number of svg.querySelectorAll(":scope > .point-number")) { + svg.insertBefore(number, pieces); + } } /** Crée l'écran du tutoriel dans section (vide dans index.html). */ @@ -215,9 +226,12 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { replay.append(icon("replay")); replay.setAttribute("aria-label", HOME_TEXTS.replayLabel); replay.title = HOME_TEXTS.replayLabel; - const text = element(section, "div", { id: "tutorial-text", className: "tutorial-text" }); - const task = element(section, "p", { id: "tutorial-task", className: "tutorial-task" }); - const feedback = element(section, "p", { + // Le texte, la consigne « à vous » et la réponse : en paysage, la seule + // partie qui défile si l'écran est trop bas (app.css). + const body = element(section, "div", { className: "tutorial-body" }); + const text = element(body, "div", { id: "tutorial-text", className: "tutorial-text" }); + const task = element(body, "p", { id: "tutorial-task", className: "tutorial-task" }); + const feedback = element(body, "p", { id: "tutorial-feedback", className: "tutorial-feedback", }); @@ -287,8 +301,16 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { return tutorial; }; + /** En paysage, le plateau occupe toute la hauteur, à gauche de la + * colonne du texte (app.css), avec la géométrie du jeu en paysage. */ function geometry(): Geometry { - const width = svg.clientWidth || Math.min(window.innerWidth, 640); + const box = svg.getBoundingClientRect(); + if (options.landscape()) { + const width = box.width || window.innerWidth; + const height = box.height || window.innerHeight; + return boardGeometry("landscape", width / height, height / 520); + } + const width = box.width || Math.min(window.innerWidth, 640); return boardGeometry("portrait", PORTRAIT_ASPECT, width / 640); } @@ -337,7 +359,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { }; renderBoard(svg, g, boardModel(current), decor); if (screen?.path === true) { - drawPath(svg, g); + drawPath(svg, g, (svg.getBoundingClientRect().width || g.width) / g.width); } } @@ -412,6 +434,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { function markSolved(): void { solved = true; + section.toggleAttribute("data-solved", true); next.classList.remove("secondary"); take.disabled = true; drop.disabled = true; @@ -553,6 +576,7 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { selected = null; targets = new Map(); solved = false; + section.toggleAttribute("data-solved", false); feedback.textContent = ""; feedback.dataset.verdict = ""; task.hidden = true; diff --git a/app/src/ui/updates.ts b/app/src/ui/updates.ts index 7e4201e..ff8b265 100644 --- a/app/src/ui/updates.ts +++ b/app/src/ui/updates.ts @@ -1,8 +1,9 @@ // La mise à jour de l'application : le service worker (web/sw.js) installe // chaque nouvelle version publiée dans son propre cache, puis attend -// l'accord du joueur. L'application affiche alors le bandeau « Nouvelle -// version disponible » ; « Mettre à jour » active la nouvelle version et -// recharge la page, qui reprend la partie enregistrée. +// l'accord du joueur. L'application affiche alors, entre deux tours, le +// bandeau « Nouvelle version prête. » en bas de l'écran ; « Mettre à jour » +// active la nouvelle version et recharge la page, qui reprend la partie +// enregistrée. // // Copyright (C) 2026 les auteurs d'Ustagammon. // Licence : GPLv3 ou ultérieure (voir LICENSE). @@ -25,17 +26,27 @@ export interface Updates { export const SKIP_WAITING = "skip-waiting"; /** - * Le bandeau « Nouvelle version disponible » est-il montré ? Quand une - * version attend, jamais pendant un coup en cours (des pions déjà déplacés, - * ou l'ordinateur qui joue), ni par-dessus le bandeau « Reprendre - * l'ancienne ». Fonction pure, testée sous Node. + * Est-on entre deux tours ? Oui quand la partie attend un lancer de dés + * (aucun pion déplacé) ou qu'elle est finie ; non dès que des dés sont à + * jouer, qu'un double attend sa réponse, ou au jet d'ouverture. Fonction + * pure, testée sous Node. + */ +export function betweenTurns(phase: string, pendingHalfMoves: number): boolean { + return phase === "over" || (phase === "rolling" && pendingHalfMoves === 0); +} + +/** + * Le bandeau « Nouvelle version prête. » est-il montré ? Quand une version + * attend, seulement entre deux tours (jamais pendant un coup en cours, ni + * quand l'ordinateur joue), et jamais par-dessus le bandeau « Reprendre + * l'ancienne », qui occupe la même place. Fonction pure, testée sous Node. */ export function updateBannerShown( available: boolean, - moveInProgress: boolean, + betweenTwoTurns: boolean, otherBanner: boolean, ): boolean { - return available && !moveInProgress && !otherBanner; + return available && betweenTwoTurns && !otherBanner; } /** diff --git a/app/tests/steps/regler-l-application.steps.ts b/app/tests/steps/regler-l-application.steps.ts index 3c24d49..e1097ef 100644 --- a/app/tests/steps/regler-l-application.steps.ts +++ b/app/tests/steps/regler-l-application.steps.ts @@ -20,7 +20,7 @@ import { memoryStorage } from "../../src/storage/open-library.js"; import { BANNER_MS } from "../../src/ui/app.js"; import { createHaptics } from "../../src/ui/haptics.js"; import { nextTheme, parseSettings, type Settings } from "../../src/ui/settings.js"; -import { updateBannerShown } from "../../src/ui/updates.js"; +import { betweenTurns, updateBannerShown } from "../../src/ui/updates.js"; import { loadNodeEngine } from "../support/node-engine.js"; setDefaultTimeout(30_000); @@ -168,23 +168,34 @@ Then("il passe à clair, puis à sombre, puis revient à automatique", () => { // ------------------------------------------------------------ Mise à jour // Le bandeau dépend de deux choses : une version attend (le service worker -// l'a installée), et aucun coup n'est en cours de saisie. La partie est -// celle du domaine, sur le vrai moteur. +// l'a installée), et la partie est entre deux tours. La partie est celle du +// domaine, sur le vrai moteur. let updateAvailable = false; Given("une nouvelle version d'Ustagammon est disponible", () => { updateAvailable = true; }); -Given("vous avez déjà déplacé un pion de votre coup", async () => { +const shownNow = (otherBanner = false) => + updateBannerShown( + updateAvailable, + betweenTurns(game.state.phase, game.pendingHalfMoves.length), + otherBanner, + ); + +Given("vous avez des dés à jouer", () => { // Jet d'ouverture 3-1 : vous commencez. game = Game.start(engine, { dice: fixedDice([3, 1, 6, 4, 5, 2]), ply: 0 }); assert.equal(game.state.turn, "player"); + assert.equal(game.state.phase, "moving"); +}); + +When("vous déplacez un pion de votre coup", async () => { assert.ok(await game.playHalfMove({ from: 8, to: 5 })); }); Then("l'application ne vous propose pas encore de mettre à jour", () => { - assert.ok(!updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false)); + assert.ok(!shownNow()); }); When("vous avez joué votre coup", async () => { @@ -193,7 +204,7 @@ When("vous avez joué votre coup", async () => { }); Then("l'application vous propose de mettre à jour", () => { - assert.ok(updateBannerShown(updateAvailable, game.pendingHalfMoves.length > 0, false)); + assert.ok(shownNow()); // Jamais par-dessus le bandeau « Reprendre l'ancienne ». - assert.ok(!updateBannerShown(updateAvailable, false, true)); + assert.ok(!shownNow(true)); }); diff --git a/docs/scenarios/regler-l-application.feature b/docs/scenarios/regler-l-application.feature index 9a56668..c4083bf 100644 --- a/docs/scenarios/regler-l-application.feature +++ b/docs/scenarios/regler-l-application.feature @@ -52,13 +52,16 @@ Fonctionnalité: Régler l'application Scénario: Comment mettre l'application à jour ? Dès qu'une nouvelle version d'Ustagammon est publiée, l'application la - télécharge d'elle-même, puis affiche « Nouvelle version disponible », - jamais pendant que vous jouez un coup. « Mettre à jour » l'installe et - vous ramène où vous en étiez. Dans le menu, « Rechercher une mise à + télécharge d'elle-même, puis affiche en bas de l'écran « Nouvelle + version prête. », seulement entre deux tours, jamais pendant que vous + jouez un coup. « Mettre à jour » l'installe et vous ramène où vous en + étiez. Dans le menu, « Rechercher une mise à jour » vérifie tout de suite s'il y en a une. Étant donné qu'une nouvelle version d'Ustagammon est disponible - Et que vous avez déjà déplacé un pion de votre coup + Et que vous avez des dés à jouer + Alors l'application ne vous propose pas encore de mettre à jour + Quand vous déplacez un pion de votre coup Alors l'application ne vous propose pas encore de mettre à jour Quand vous avez joué votre coup Alors l'application vous propose de mettre à jour diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 104fec8..039b5d6 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -11,7 +11,8 @@ // Lancé par tests/browser.sh. Le second site sert la même chose depuis un // dossier que ce script modifie : il y publie une deuxième version pour // essayer la mise à jour. Captures de la recette design (Chromium) dans -// /accueil-tutoriel/, en 412×915 et 915×412. +// /accueil-tutoriel/, en 412×915 et 915×412, et aussi +// en 844×320 pour le tutoriel. import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; @@ -29,6 +30,8 @@ const SIZES = [ [412, 915], [915, 412], ]; +// Le tutoriel est aussi vérifié dans le plus petit paysage de la recette. +const TUTORIAL_SIZES = [...SIZES, [844, 320]]; const browser = await browserType.launch(); const problems = []; @@ -102,12 +105,12 @@ async function until(page, what, test, arg, ms = 30_000) { throw new Error(`${what} : attente dépassée (${JSON.stringify(last)})`); } -/** Range une capture de l'écran courant dans les deux tailles (Chromium). */ -async function capture(page, name, settle = async () => {}) { +/** Range une capture de l'écran courant dans chaque taille (Chromium). */ +async function capture(page, name, settle = async () => {}, sizes = SIZES) { if (!full) { return; } - for (const [width, height] of SIZES) { + for (const [width, height] of sizes) { await page.setViewportSize({ width, height }); await sleep(250); await settle(); @@ -139,6 +142,60 @@ async function tutorialReady(page, hash) { }, hash); } +/** Recette du tutoriel dans la taille courante : numéros des flèches de + * 12 px au moins ; en paysage, rien ne défile et les boutons sont en vue. */ +async function checkTutorialView(page, name) { + const view = await page.evaluate(() => { + const svg = document.getElementById("tutorial-board"); + const box = svg.getBoundingClientRect(); + const vb = svg.viewBox.baseVal; + const scale = Math.min(box.width / vb.width, box.height / vb.height); + const numbers = [...svg.querySelectorAll(".point-number")] + .map((t) => Number(t.getAttribute("font-size")) * scale); + const buttons = ["tutorial-back", "tutorial-next", "tutorial-drop", "tutorial-take"] + .map((id) => document.getElementById(id)) + .filter((el) => el !== null && el.closest("[hidden]") === null) + .map((el) => { + const r = el.getBoundingClientRect(); + return { id: el.id, top: r.top, bottom: r.bottom }; + }); + return { + landscape: document.documentElement.classList.contains("landscape"), + minNumber: numbers.length === 0 ? 0 : Math.min(...numbers), + scroll: document.scrollingElement.scrollHeight - innerHeight, + body: (() => { + const b = document.querySelector("#tutorial .tutorial-body"); + return b.scrollHeight - b.clientHeight; + })(), + height: innerHeight, + buttons, + }; + }); + const size = `${name} en ${await page.evaluate(() => `${innerWidth}x${innerHeight}`)}`; + check(view.minNumber >= 12, `${size} : numéros des flèches de ${view.minNumber.toFixed(1)} px`); + if (view.landscape) { + check(view.scroll <= 1, `${size} : la page défile de ${view.scroll} px`); + check(view.body <= 1, `${size} : le texte défile de ${view.body} px`); + if (view.body > tutorialBodyScroll.px) { + tutorialBodyScroll = { px: view.body, where: size }; + } + for (const b of view.buttons) { + check(b.top >= 0 && b.bottom <= view.height + 0.5, + `${size} : « ${b.id} » hors de l'écran (${b.top}-${b.bottom} pour ${view.height})`); + } + } + tutorialViews++; + tutorialMinNumber = Math.min(tutorialMinNumber, view.minNumber); +} +let tutorialViews = 0; +let tutorialBodyScroll = { px: 0, where: "" }; +let tutorialMinNumber = Infinity; + +/** Capture d'un écran du tutoriel, recette comprise, dans les trois tailles. */ +async function tutorialCapture(page, name) { + await capture(page, name, () => checkTutorialView(page, name), TUTORIAL_SIZES); +} + /** Touche une case du plateau du tutoriel. */ async function touch(page, place) { await page.click(`#tutorial-board [data-place="${place}"]`); @@ -210,8 +267,17 @@ async function playTouches(page, touches) { if (step === 2) { check(await page.locator("#tutorial-board .tutorial-path").count() === 1, "étape 2 : pas de chemin en pointillés"); + // Sous les pions et sous les numéros des flèches. + const order = await page.evaluate(() => { + const children = [...document.getElementById("tutorial-board").children]; + const at = (selector) => children.findIndex((c) => c.matches(selector)); + const numbers = children.filter((c) => c.matches(".point-number")).map((c) => children.indexOf(c)); + return { path: at(".tutorial-path"), pieces: at("g.pieces"), firstNumber: Math.min(...numbers) }; + }); + check(order.path >= 0 && order.path < order.pieces && order.path < order.firstNumber, + `étape 2 : le chemin passe au-dessus des pions ou des numéros ${JSON.stringify(order)}`); } - await capture(page, `tutoriel-${step}`); + await tutorialCapture(page, `tutoriel-${step}`); screens++; await page.click("#tutorial-next"); const practice = await page.evaluate(() => location.hash.endsWith("/a-vous")); @@ -222,11 +288,11 @@ async function playTouches(page, touches) { await tutorialReady(page, avous); check(await page.evaluate(() => document.getElementById("tutorial-next").classList.contains("secondary")), `étape ${step}, à vous : « Continuer » principal avant le geste`); - await capture(page, `tutoriel-${step}-a-vous`); + await tutorialCapture(page, `tutoriel-${step}-a-vous`); if (step === 8) { await page.click("#tutorial-drop"); - check(await verdict(page) === "wrong", "étape 8 : « Je passe » accepté"); - await capture(page, "tutoriel-8-a-vous-refuse"); + check(await verdict(page) === "wrong", "étape 8 : « Passer » accepté"); + await tutorialCapture(page, "tutoriel-8-a-vous-refuse"); await page.click("#tutorial-take"); await until(page, "étape 8 juste", () => document.getElementById("tutorial-feedback").dataset.verdict === "right"); @@ -238,7 +304,7 @@ async function playTouches(page, touches) { await until(page, "pion revenu", () => document.querySelectorAll("#tutorial-board .hit.can-move").length > 0); const words = await page.textContent("#tutorial-feedback"); check(!/erreur/i.test(words), `étape ${step} : « erreur » dans le refus`); - await capture(page, `tutoriel-${step}-a-vous-refuse`); + await tutorialCapture(page, `tutoriel-${step}-a-vous-refuse`); await playTouches(page, moves.right); check(await verdict(page) === "right", `étape ${step} : bon coup refusé`); check(await page.locator("#tutorial-board .hit.can-move").count() === 0, @@ -248,13 +314,13 @@ async function playTouches(page, touches) { `étape ${step} : « Continuer » pas principal après le bon geste`); const vibrated = await page.evaluate(() => window.__vibrations.at(-1)); check(vibrated === "12", `étape ${step} : vibration ${vibrated}`); - await capture(page, `tutoriel-${step}-a-vous-reussi`); + await tutorialCapture(page, `tutoriel-${step}-a-vous-reussi`); screens++; await page.click("#tutorial-next"); } await until(page, "fin du tutoriel", () => location.hash === "#/tutoriel/fin" && document.getElementById("tutorial").dataset.ready !== undefined); - await capture(page, "tutoriel-fin"); + await tutorialCapture(page, "tutoriel-fin"); // Le retour du téléphone : l'écran d'avant. await page.goBack(); await tutorialReady(page, "#/tutoriel/8/a-vous"); @@ -262,7 +328,9 @@ async function playTouches(page, touches) { await until(page, "fin", () => location.hash === "#/tutoriel/fin"); await page.click("#tutorial-next"); await until(page, "première partie", () => location.hash === "#/" && window.__shown("game")); - log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin`); + log(`tutoriel : ${screens} écrans, gestes refusés puis justes sur les 4 écrans « à vous », fin` + + (full ? ` ; recette sur ${tutorialViews} vues, numéros de ${tutorialMinNumber.toFixed(1)} px au moins, ` + + `texte défilant au plus de ${tutorialBodyScroll.px} px${tutorialBodyScroll.px > 0 ? ` (${tutorialBodyScroll.where})` : ""}` : "")); // Le choix ne revient plus : l'accueil, ensuite. await page.goto(base); @@ -391,8 +459,27 @@ if (browserName === "chromium") { }; open.onerror = () => resolve(null); })); - const gameBefore = await journal(); check(await page.isHidden("#update-banner"), "bandeau de mise à jour sans nouvelle version"); + // Si vous avez la main (jet d'ouverture), le bandeau attend la fin du + // tour : on publie la nouvelle version d'abord, puis on joue le coup. + const toPlay = () => page.evaluate(() => { + if (document.body.classList.contains("busy")) { + return "busy"; + } + const usable = (id) => { + const b = document.getElementById(id); + return b !== null && !b.hidden && !b.disabled; + }; + for (const id of ["roll", "again", "confirm", "skip", "take"]) { + if (usable(id)) { + return id; + } + } + if (document.querySelector("#board .hit.can-land")) { + return "land"; + } + return document.querySelector("#board .hit.can-move") ? "move" : "busy"; + }); // Une deuxième version publiée : autre version.json, autre sw.js. writeFileSync(`${updateDir}/version.json`, '{"commit": "0123456789abcdef0123", "dirty": false}\n'); const sw = readFileSync(`${updateDir}/sw.js`, "utf8"); @@ -400,9 +487,40 @@ if (browserName === "chromium") { await page.click("#menu-button"); await page.waitForSelector("#menu", { state: "visible" }); await page.click("#menu-update"); + await until(page, "nouvelle version trouvée", () => + document.getElementById("update-text")?.textContent !== "Recherche d'une mise à jour…", undefined, 120_000); + // Pendant votre coup, jamais de bandeau : seulement entre deux tours. + let waited = false; + for (let i = 0; i < 40; i++) { + const todo = await toPlay(); + if (todo === "roll" || todo === "again") { + break; + } + if (todo === "busy") { + await sleep(100); + continue; + } + if (todo === "move" || todo === "land" || todo === "confirm") { + await sleep(400); + check(await page.isHidden("#update-banner"), `bandeau de mise à jour pendant votre coup (${todo})`); + waited = true; + } + await page.click(todo === "move" ? "#board .hit.can-move" : todo === "land" ? "#board .hit.can-land" : `#${todo}`); + await sleep(200); + } await until(page, "bandeau de mise à jour", () => window.__shown("update-banner") && window.__shown("update-apply"), undefined, 120_000); - check((await page.textContent("#update-text")) === "Nouvelle version disponible.", + check((await page.textContent("#update-text")) === "Nouvelle version prête.", `bandeau : « ${await page.textContent("#update-text")} »`); + // En bas, au-dessus de la barre d'actions, comme « Reprendre l'ancienne ». + const place = await page.evaluate(() => { + const banner = document.getElementById("update-banner").getBoundingClientRect(); + const actions = document.getElementById("actions").getBoundingClientRect(); + const top = document.querySelector(".topbar").getBoundingClientRect(); + return { bottom: banner.bottom, top: banner.top, actions: actions.top, bar: top.bottom }; + }); + check(place.bottom <= place.actions && place.top > place.bar, + `bandeau de mise à jour mal placé : ${JSON.stringify(place)}`); + const gameBefore = await journal(); await capture(page, "09-mise-a-jour"); await Promise.all([page.waitForEvent("load", { timeout: 60_000 }), page.click("#update-apply")]); await until(page, "partie reprise", () => location.hash === "#/" && !document.body.classList.contains("busy"), undefined, 60_000); @@ -410,7 +528,7 @@ if (browserName === "chromium") { await page.goto(`${updateBase}#/a-propos`); await until(page, "nouvelle version", () => document.getElementById("about-version")?.textContent === "Version publiée : 0123456789ab."); - log("mise à jour : deuxième version publiée, bandeau, « Mettre à jour », partie retrouvée, nouvelle version"); + log(`mise à jour : deuxième version publiée, bandeau ${waited ? "retenu pendant votre coup, puis " : ""}entre deux tours, « Mettre à jour », partie retrouvée, nouvelle version`); // Rien de neuf : « Vous avez la dernière version ». await page.goto(`${updateBase}#/`); await until(page, "partie", () => !document.body.classList.contains("busy"), undefined, 60_000); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 00fc3de..7a45b2e 100644 --- a/tests/browser/play.mjs +++ b/tests/browser/play.mjs @@ -815,7 +815,7 @@ async function checkAddresses() { const seen = await tab.evaluate(() => { const bar = document.getElementById("help-bar").getBoundingClientRect(); const target = document.querySelector( - "#help-play .question.open, #help-glossary dt.current", + "#help-play .question.open, #help-glossary .term-card.current", ).getBoundingClientRect(); return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top }; }); @@ -882,9 +882,15 @@ async function checkAddresses() { } // Le bouton « Aide » ouvre la question du moment de la partie // (help-context.ts) ; ici, l'onglet « Jouer » sans question. - const helpHref = await tab.getAttribute("#help-button", "href"); + // L'adresse est lue au moment du toucher : la partie qui vient de + // s'ouvrir peut encore changer de moment (jet d'ouverture). + const helpHref = await tab.evaluate(() => { + const button = document.getElementById("help-button"); + const href = button.getAttribute("href"); + button.click(); + return href; + }); check(/^#\/aide(\/[a-z0-9-]+)?$/.test(helpHref), `bouton Aide : ${helpHref}`); - await tab.click("#help-button"); await expectScreen(tab, "aide par son lien", (s) => s.hash === helpHref && s.help && s.play); if (helpHref !== "#/aide") { await tab.goBack(); diff --git a/web/app.css b/web/app.css index ae94cf1..a8ddcf9 100644 --- a/web/app.css +++ b/web/app.css @@ -1875,13 +1875,6 @@ html.bare .topbar { width: 100%; } -/* Bandeau de mise à jour : en haut, discret, jamais sur la barre - * d'actions ni sur « Reprendre l'ancienne ». */ -.update-banner { - top: var(--space-2); - bottom: auto; -} - /* Tutoriel. */ .tutorial-head { display: flex; @@ -2039,8 +2032,132 @@ html.bare .topbar { stroke: var(--brand-noyer); } -/* En paysage, le plateau du tutoriel tient dans la hauteur de l'écran. */ -html.landscape .tutorial-board, +/* En paysage, comme la position partagée : le plateau à gauche sur toute + * la hauteur (avec la colonne gauche du cadre), et à droite une colonne + * avec la barre de progression, le titre, le texte, la consigne « à vous » + * et les boutons, le tout sans défilement de la page. Si le texte est trop + * long pour l'écran, lui seul défile : les boutons restent en vue. */ +html.landscape .tutorial { + position: fixed; + inset: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(288px, 44vw, 400px); + grid-template-rows: auto auto minmax(0, 1fr) auto auto; + max-width: none; + margin: 0; + padding: 0; + overflow: hidden; + background: var(--background); +} + +html.landscape .tutorial > * { + grid-column: 2; + margin-inline: 0; + padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right)); +} + +html.landscape .tutorial .overline { + display: none; +} + +html.landscape .tutorial-head { + grid-row: 1; + min-height: 44px; + padding-top: 0; +} + +html.landscape .tutorial-title { + grid-row: 2; + margin: 0 0 var(--space-1); + padding-right: calc(44px + var(--space-4)); + font-size: 20px; + line-height: 26px; +} + +/* La boîte du plateau s'efface : le plateau et « Revoir » se placent + * eux-mêmes dans la grille. */ +html.landscape .tutorial-board-box { + display: contents; +} + +html.landscape .tutorial-board { + grid-row: 1 / -1; + grid-column: 1; + width: 100%; + height: 100%; + margin: 0; + aspect-ratio: auto; + background: var(--frame); +} + +html.landscape #tutorial-replay { + position: static; + grid-row: 2; + grid-column: 2; + justify-self: end; + align-self: start; + margin: -9px calc(var(--space-2) + env(safe-area-inset-right)) 0 0; + padding: 0; +} + +html.landscape .tutorial-body { + grid-row: 3; + min-height: 0; + overflow-y: auto; + font-size: 15px; + line-height: 20px; +} + +html.landscape .tutorial-text { + margin: 0; + padding-right: 0; +} + +html.landscape .tutorial-text p { + margin-bottom: var(--space-2); +} + +html.landscape .tutorial-task { + margin: 0; + padding: var(--space-2) 12px; +} + +html.landscape .tutorial-feedback { + min-height: 0; + margin: var(--space-2) 0 0; +} + +html.landscape .tutorial-cube { + grid-row: 4; + margin-top: var(--space-2); +} + +/* La réponse au double donnée et saluée, les deux boutons n'ont plus + * d'usage : en paysage, leur place revient au texte. */ +html.landscape .tutorial[data-solved] .tutorial-cube { + display: none; +} + +/* Écran très bas (844 × 320) : texte un cran plus petit. */ +@media (max-height: 360px) { + html.landscape .tutorial-body { + font-size: 14px; + line-height: 18px; + } + + html.landscape .tutorial-title { + font-size: 18px; + line-height: 24px; + } +} + +html.landscape .tutorial-nav { + grid-row: 5; + margin: 0; + padding-block: var(--space-2) calc(var(--space-2) + env(safe-area-inset-bottom)); +} + +/* Le premier lancement, en paysage : le plateau tient dans la hauteur. */ html.landscape .welcome-board { width: min(100%, calc(70vh * 640 / 520)); height: auto; diff --git a/web/index.html b/web/index.html index 27c4e72..d2dc71b 100644 --- a/web/index.html +++ b/web/index.html @@ -188,7 +188,8 @@ - +