diff --git a/app/src/ui/home-texts.ts b/app/src/ui/home-texts.ts index f8bf0d6..d187d03 100644 --- a/app/src/ui/home-texts.ts +++ b/app/src/ui/home-texts.ts @@ -41,7 +41,9 @@ export const HOME_TEXTS = { rulesContinue: (step: number) => `Reprendre à l'étape ${step}`, rulesAgain: "Revoir", progress: (played: number, won: number) => - `${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`, + played === 0 + ? "aucune partie jouée" + : `${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`, footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.", // Installation diff --git a/app/src/ui/home-view.ts b/app/src/ui/home-view.ts index c420426..ebf23d1 100644 --- a/app/src/ui/home-view.ts +++ b/app/src/ui/home-view.ts @@ -290,10 +290,11 @@ export function createHomeView(options: HomeViewOptions): HomeView { if (state.lessons !== null) { parts.push(LESSON_TEXTS.homeSummary(state.lessons.finished, state.lessons.total)); } - if (state.progress !== null && state.progress.played > 0) { + if (state.progress !== null) { parts.push(HOME_TEXTS.progress(state.progress.played, state.progress.won)); } - progress.textContent = parts.join(" · "); + const line = parts.join(" · "); + progress.textContent = line.charAt(0).toUpperCase() + line.slice(1); }, }; } diff --git a/app/src/ui/lesson-texts.ts b/app/src/ui/lesson-texts.ts index e4cf4b6..eb42de1 100644 --- a/app/src/ui/lesson-texts.ts +++ b/app/src/ui/lesson-texts.ts @@ -53,5 +53,7 @@ export const LESSON_TEXTS = { homeAllDoneDetail: "Vous pouvez les revoir quand vous voulez.", allLessons: "Toutes les leçons", homeSummary: (finished: number, total: number) => - `${plural(finished, "leçon terminée", "leçons terminées")} sur ${total}`, + finished === 0 + ? `Aucune leçon terminée sur ${total}` + : `${plural(finished, "leçon terminée", "leçons terminées")} sur ${total}`, } as const; diff --git a/app/src/ui/lessons-view.ts b/app/src/ui/lessons-view.ts index 47bd3cf..c7201a1 100644 --- a/app/src/ui/lessons-view.ts +++ b/app/src/ui/lessons-view.ts @@ -86,9 +86,10 @@ export function createLessonsView(section: HTMLElement): LessonsView { const head = element(section, "div", { className: "lessons-intro" }); element(head, "h2", { text: LESSON_TEXTS.listTitle }); element(head, "p", { className: "muted", text: LESSON_TEXTS.listLead }); - const rules = element(section, "p", { className: "lessons-rules caption" }); - rules.append(`${LESSON_TEXTS.rulesReminder} · `); - const review = element(rules, "a", { id: "lessons-rules", text: LESSON_TEXTS.rulesReview }); + // Le rappel des règles : toute la ligne est le lien, haute de 44 px. + const review = element(section, "a", { id: "lessons-rules", className: "lessons-rules" }); + review.append(`${LESSON_TEXTS.rulesReminder} · `); + element(review, "span", { text: LESSON_TEXTS.rulesReview }); review.href = "#/tutoriel/1"; review.setAttribute("aria-label", LESSON_TEXTS.rulesReviewLabel); const status = element(section, "p", { id: "lessons-status", className: "muted" }); diff --git a/app/src/ui/tutorial-view.ts b/app/src/ui/tutorial-view.ts index 73a15ec..85fc547 100644 --- a/app/src/ui/tutorial-view.ts +++ b/app/src/ui/tutorial-view.ts @@ -287,14 +287,16 @@ export function createTutorialView(options: TutorialViewOptions): TutorialView { skip.textContent = course.texts.quit; skip.setAttribute("aria-label", course.texts.quitLabel); const overline = element(section, "p", { id: idOf("overline"), className: "overline" }); - const title = element(section, "h2", { id: idOf("title"), className: "tutorial-title" }); + // Le titre, et « Revoir » à sa droite (en portrait comme en paysage). + const titleRow = element(section, "div", { className: "tutorial-title-row" }); + const title = element(titleRow, "h2", { id: idOf("title"), className: "tutorial-title" }); const boardBox = element(section, "div", { className: "tutorial-board-box" }); const svg = document.createElementNS(SVG_NS, "svg"); svg.id = idOf("board"); svg.setAttribute("class", "board tutorial-board"); svg.setAttribute("role", "img"); boardBox.append(svg); - const replay = element(boardBox, "button", { + const replay = element(titleRow, "button", { id: idOf("replay"), className: "icon-button tutorial-replay", }); diff --git a/app/tests/lessons.test.ts b/app/tests/lessons.test.ts index c1d31cd..d933954 100644 --- a/app/tests/lessons.test.ts +++ b/app/tests/lessons.test.ts @@ -106,7 +106,7 @@ test("cinq leçons, de deux à quatre exercices chacune, avec un titre et une fi lessons.map((l) => l.title), [ "Les ouvertures", - "Construire ses cases", + "Faire des points", "Frapper ou ne pas frapper", "La course et le compte de points", "Sortir sans se faire frapper", @@ -217,7 +217,9 @@ test("chaque coup attendu est le meilleur coup du moteur à 2 plis, avec plus de assert.equal(report.length, 16); }); -test("chaque coup illustré est le meilleur coup du moteur à 2 plis", async () => { +// Couvre aussi le « nettement devant tous les autres » de la leçon 1 (étape +// 1) : chaque coup illustré a plus de MIN_GAP d'avance sur le deuxième. +test("chaque coup illustré est le meilleur coup du moteur à 2 plis, avec plus de 0,04 d'avance", async () => { const engine = await loadNodeEngine(); let illustrated = 0; for (const [name, screen] of allScreens()) { diff --git a/docs/lecons/lecon-1.md b/docs/lecons/lecon-1.md index a3985b5..104fd0b 100644 --- a/docs/lecons/lecon-1.md +++ b/docs/lecons/lecon-1.md @@ -27,9 +27,9 @@ Vous avez 4 et 2. Faites un point dans votre jan intérieur. - position: 4HPwATDgc/ABMA -La flèche 7 ouvre votre [jan extérieur](jan-exterieur.md), juste à côté de la barre. Avec la flèche 6, déjà fermée par vos cinq pions, elle forme deux points côte à côte. +La flèche 7 ouvre votre [jan extérieur](jan-exterieur.md), juste à côté de la barre. Fermée, elle formerait avec la flèche 6, où sont déjà vos cinq pions, deux points côte à côte. -Les deux pions noirs de votre flèche 1 doivent passer par là : depuis la flèche 1, un 5 les mène sur la flèche 6 et un 6 sur la flèche 7. +Les deux pions noirs de votre flèche 1 doivent passer par là : depuis la flèche 1, un 6 les mène sur la flèche 7, et un 5 est déjà bloqué par votre point 6. ### À vous diff --git a/docs/lecons/lecon-2.md b/docs/lecons/lecon-2.md index 864e60b..3157f3e 100644 --- a/docs/lecons/lecon-2.md +++ b/docs/lecons/lecon-2.md @@ -1,6 +1,6 @@ -# Construire ses cases +# Faire des points -Leçon 2 d'Ustagammon : construire ses cases et faire des points. Texte sous +Leçon 2 d'Ustagammon : construire son jeu en faisant des points. Texte sous licence CC BY-SA 4.0. Format : voir README.md. ## 1. Deux pions font un point @@ -27,7 +27,7 @@ Même position, vous avez 6 et 2. Votre pion de la flèche 5 est seul : couvrez- - position: 4HPwATDQZ+EBMA -Un pion posé sur les flèches 7 à 11, dans votre [jan extérieur](jan-exterieur.md), peut entrer dans votre jan intérieur avec un seul dé. +Un pion posé sur les flèches 7 à 12, dans votre [jan extérieur](jan-exterieur.md), peut entrer dans votre jan intérieur avec un seul dé. Ici, un pion blanc est seul sur la flèche 5, un autre sur la flèche 9. diff --git a/docs/lecons/lecon-3.md b/docs/lecons/lecon-3.md index 661b1ce..9b666ab 100644 --- a/docs/lecons/lecon-3.md +++ b/docs/lecons/lecon-3.md @@ -11,7 +11,7 @@ sous licence CC BY-SA 4.0. Format : voir README.md. Un pion noir est seul sur votre flèche 7 : c'est un [blot](blot.md). Avec 6 et 1, je joue 13/7* 8/7. L'astérisque marque le pion frappé : il part sur la [barre](barre.md). -Mes deux pions restent ensemble sur la flèche 7 : le point est fait, aucun pion noir ne peut plus s'y poser. +Les deux pions blancs restent ensemble sur la flèche 7 : le point est fait, aucun pion noir ne peut plus s'y poser. ### À vous diff --git a/docs/lecons/lecon-4.md b/docs/lecons/lecon-4.md index e9b4460..4d948af 100644 --- a/docs/lecons/lecon-4.md +++ b/docs/lecons/lecon-4.md @@ -57,7 +57,7 @@ Vous avez 5 et 2. Ici, les pions de l'ordinateur ont 81 flèches à parcourir, les vôtres 110 : il a 29 points d'avance. Son dernier pion est seul sur votre flèche 9. -Frappé, ce pion part sur la barre, et le compte de l'ordinateur remonte : de la flèche 16 de son côté jusqu'à la barre, 9 de plus. +Frappé, ce pion part sur la barre, et le compte de l'ordinateur remonte : pour l'ordinateur, ce pion compte 16 ; sur la barre, il compte 25 : 9 de plus. ### À vous diff --git a/docs/scenarios/suivre-une-lecon.feature b/docs/scenarios/suivre-une-lecon.feature index 0c7e9f0..8fb79d4 100644 --- a/docs/scenarios/suivre-une-lecon.feature +++ b/docs/scenarios/suivre-une-lecon.feature @@ -1,8 +1,8 @@ # language: fr Fonctionnalité: Progresser avec les leçons - Cinq leçons courtes, sur le vrai plateau : les ouvertures, construire ses - cases, frapper ou non, la course et le compte de points, sortir sans se + Cinq leçons courtes, sur le vrai plateau : les ouvertures, faire des + points, frapper ou non, la course et le compte de points, sortir sans se faire frapper. Chacune explique une idée en quelques écrans, puis vous fait jouer le coup du maître. GNU Backgammon vérifie chaque coup. diff --git a/tests/browser/accueil.mjs b/tests/browser/accueil.mjs index 361b881..13b0d1c 100644 --- a/tests/browser/accueil.mjs +++ b/tests/browser/accueil.mjs @@ -16,7 +16,7 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; -import { hiddenUnderActionBar, tutorialWidthProblems, tutorialWidths } from "./recette.mjs"; +import { hiddenUnderActionBar, missingTopbar, tutorialWidthProblems, tutorialWidths } from "./recette.mjs"; const [base, browserName, shots, updateBase, updateDir] = process.argv.slice(2); const browserType = { chromium, firefox }[browserName]; @@ -117,6 +117,8 @@ async function capture(page, name, settle = async () => {}, sizes = SIZES) { await settle(); const hidden = await hiddenUnderActionBar(page); check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`); + const topbar = await missingTopbar(page); + check(topbar === null, `${name} : ${topbar}`); await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true }); captures++; } diff --git a/tests/browser/lecons.mjs b/tests/browser/lecons.mjs index 99e782b..39baffb 100644 --- a/tests/browser/lecons.mjs +++ b/tests/browser/lecons.mjs @@ -13,7 +13,7 @@ import { mkdirSync } from "node:fs"; import { chromium, firefox } from "playwright"; -import { hiddenUnderActionBar, tallButtons } from "./recette.mjs"; +import { hiddenUnderActionBar, missingTopbar, tallButtons } from "./recette.mjs"; const [base, browserName, shots] = process.argv.slice(2); const browserType = { chromium, firefox }[browserName]; @@ -68,6 +68,8 @@ async function capture(page, name, settle = async () => {}) { await settle(); const hidden = await hiddenUnderActionBar(page); check(hidden.length === 0, `${name} ${width}x${height} : caché sous la barre d'action : ${hidden.join(", ")}`); + const topbar = await missingTopbar(page); + check(topbar === null, `${name} : ${topbar}`); const tall = await tallButtons(page); check(tall.length === 0, `${name} ${width}x${height} : boutons étirés : ${tall.join(", ")}`); await page.screenshot({ path: `${dir}/${name}-${width}x${height}.png`, fullPage: true }); @@ -155,7 +157,8 @@ try { await until(page, "carte de la leçon", () => { const card = document.getElementById("home-lesson"); return location.hash === "#/accueil" && !card.hidden && !card.closest("[hidden]") && - card.textContent.includes("Leçon 1") && !document.getElementById("home-new").disabled; + card.textContent.includes("Leçon 1") && !document.getElementById("home-new").disabled && + document.getElementById("home-progress").textContent.includes(" · "); }); const home = await page.evaluate(() => ({ go: document.getElementById("home-lesson-go").getAttribute("href"), @@ -170,7 +173,7 @@ try { })); check(home.go === "#/lecons/1/1" && home.goText === "Commencer" && home.goClass.includes("secondary") && home.all === "#/lecons" && home.title === "Les ouvertures" && home.state === "À commencer · 4 exercices" && - home.summary.startsWith("0 leçon terminée sur 5") && home.primaries === 1, + home.summary.startsWith("Aucune leçon terminée sur 5 · ") && home.primaries === 1, `carte de l'accueil : ${JSON.stringify(home)}`); await capture(page, "01-accueil-carte", async () => { await page.locator("#home-lesson").scrollIntoViewIfNeeded(); @@ -188,6 +191,8 @@ try { [...document.querySelectorAll("#lessons a.button:not(.secondary)")].filter((el) => !el.closest("[hidden]")).length); check(primaries === 1, `liste : ${primaries} actions principales`); check(await page.getAttribute("#lessons-rules", "href") === "#/tutoriel/1", "liste : rappel des règles"); + const rulesHeight = await page.evaluate(() => document.getElementById("lessons-rules").getBoundingClientRect().height); + check(rulesHeight >= 44, `liste : rappel des règles haut de ${rulesHeight} px`); await capture(page, "02-liste"); log("liste : cinq leçons, la première proposée, une seule action principale, rappel des règles"); @@ -208,6 +213,18 @@ try { explain.segments === 4 && explain.skip === "#/lecons" && explain.back === "#/lecons" && explain.next === "#/lecons/1/1/a-vous" && explain.topbar === "none" && explain.glossary.includes("#/glossaire/jan-interieur"), `explication : ${JSON.stringify(explain)}`); + // « Revoir » sur la ligne du titre ; « Retour » et « Continuer » en bas + // de l'écran, sous le pouce (recette 1g). + const layout = await page.evaluate(() => { + const title = document.getElementById("lesson-title").getBoundingClientRect(); + const replay = document.getElementById("lesson-replay").getBoundingClientRect(); + const next = document.getElementById("lesson-next").getBoundingClientRect(); + return { titleTop: title.top, titleBottom: title.bottom, replayTop: replay.top, + replayBottom: replay.bottom, nextBottom: next.bottom, height: innerHeight }; + }); + check(layout.replayTop < layout.titleBottom && layout.replayBottom > layout.titleTop, + `« Revoir » hors de la ligne du titre : ${JSON.stringify(layout)}`); + check(layout.nextBottom > layout.height - 64, `« Continuer » loin du bas : ${JSON.stringify(layout)}`); check(await page.locator("#lesson-board .trace-arrow").count() > 0, "le coup illustré n'a pas laissé de traces"); await capture(page, "03-explication"); log("explication : coup illustré 8/5 6/5 joué tout seul, barre de quatre étapes, liens du glossaire"); diff --git a/tests/browser/recette.mjs b/tests/browser/recette.mjs index 0d60a23..8ee95de 100644 --- a/tests/browser/recette.mjs +++ b/tests/browser/recette.mjs @@ -99,6 +99,27 @@ export async function hiddenUnderActionBar(target) { }); } +/** + * La barre haute (Aide, Plein écran, Menu) hors de l'écran de jeu : sur une + * page (accueil, leçons, aide…) qui n'a pas son propre en-tête, elle doit + * rester visible, en paysage comme en portrait ; sans elle, le menu est + * inaccessible (recette 1g). Renvoie un problème décrit, ou null. + */ +export async function missingTopbar(target) { + return target.evaluate(() => { + const root = document.documentElement; + if (!root.classList.contains("page-open") || root.classList.contains("bare")) { + return null; + } + const bar = document.querySelector(".topbar"); + const r = bar?.getBoundingClientRect(); + const shown = bar !== null && getComputedStyle(bar).display !== "none" && r.height > 0; + return shown + ? null + : `barre haute absente sur ${root.dataset.screen} (${innerWidth}x${innerHeight})`; + }); +} + /** * Les largeurs d'un écran du tutoriel, en px CSS : le plateau, la colonne * de texte, et la largeur de la page (scrollWidth) face à celle de l'écran. diff --git a/web/app.css b/web/app.css index 3752687..6b1eaa5 100644 --- a/web/app.css +++ b/web/app.css @@ -1201,6 +1201,13 @@ html.landscape.page-open body { background: var(--background); } +/* Hors de l'écran de jeu, la barre haute (Aide, Plein écran, Menu) reste + * en paysage : seul le plateau en paysage s'en passe. Les écrans qui ont + * leur propre en-tête (bare) la masquent toujours. */ +html.landscape.page-open:not(.bare) .topbar { + display: flex; +} + /* Repli du plein écran : le paysage ne peut pas être verrouillé, l'écran * entier tourne d'un quart de tour dans le portrait. Les touchers suivent * la rotation ; le plateau et les fenêtres sont posés dans #screen. */ @@ -1876,17 +1883,20 @@ html.bare .topbar { .lessons-rules { display: flex; align-items: center; + width: fit-content; min-height: 44px; margin-top: var(--space-2); + font-size: 15px; + line-height: 20px; + color: var(--muted); + text-decoration: none; } -.lessons-rules a { - display: inline-flex; - align-items: center; - min-height: 44px; +.lessons-rules span { margin-left: var(--space-1); font-weight: 700; color: var(--accent); + text-decoration: underline; } #lessons-status:empty { @@ -2136,8 +2146,28 @@ html.bare .topbar { color: var(--muted); } -.tutorial-title { +/* En portrait, l'écran tient au moins la hauteur de l'écran : « Retour » + * et « Continuer » restent en bas, sous le pouce. */ +.tutorial { + display: flex; + flex-direction: column; + box-sizing: border-box; + min-height: 100vh; + min-height: 100dvh; +} + +/* Le titre, et « Revoir » sur sa ligne, à droite. */ +.tutorial-title-row { + display: flex; + gap: var(--space-2); + align-items: center; margin: var(--space-1) 0 var(--space-3); +} + +.tutorial-title { + flex: 1; + min-width: 0; + margin: 0; font-size: 24px; line-height: 30px; } @@ -2152,16 +2182,13 @@ html.bare .topbar { } } -.tutorial-board-box .tutorial-replay { - position: absolute; - right: var(--space-1); - bottom: calc(-44px - var(--space-1)); +.tutorial-replay { + flex: none; color: var(--accent); } .tutorial-text { margin-top: var(--space-3); - padding-right: 52px; } .tutorial-text p { @@ -2214,7 +2241,8 @@ html.bare .topbar { display: grid; grid-template-columns: 1fr 1.4fr; gap: var(--space-2); - margin-top: var(--space-6); + margin-top: auto; + padding-top: var(--space-6); } .tutorial-nav .button { @@ -2281,16 +2309,46 @@ html.landscape .tutorial-head { padding-top: 0; } -html.landscape .tutorial-title { +html.landscape .tutorial-title-row { grid-row: 2; margin: 0 0 var(--space-1); - padding-right: calc(44px + var(--space-4)); + padding-right: calc(var(--space-2) + env(safe-area-inset-right)); +} + +html.landscape .tutorial-title { 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. */ +/* Une leçon garde en paysage sa ligne « Leçon 1 · 1 sur 4 · à vous » + * au-dessus du titre (recette 1g) : une rangée de plus dans la grille. */ +html.landscape #lesson { + grid-template-rows: auto auto auto minmax(0, 1fr) auto auto; +} + +html.landscape #lesson .overline { + display: block; + grid-row: 2; +} + +html.landscape #lesson .tutorial-title-row { + grid-row: 3; +} + +html.landscape #lesson .tutorial-body { + grid-row: 4; +} + +html.landscape #lesson .tutorial-cube { + grid-row: 5; +} + +html.landscape #lesson .tutorial-nav { + grid-row: 6; +} + +/* La boîte du plateau s'efface : le plateau se place lui-même dans la + * grille. */ html.landscape .tutorial-board-box { display: contents; } @@ -2305,16 +2363,6 @@ html.landscape .tutorial-board { background: var(--frame); } -html.landscape .tutorial-board-box .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;