Recette 1g : textes corrigés, boutons en bas, barre haute en paysage

Leçon 1 §2 (flèche 7 vide, 5 déjà bloqué), leçon 2 « Faire des points »,
flèches 7 à 12, reformulations des leçons 3 et 4. Écran des leçons et du
tutoriel : « Retour » et « Continuer » en bas en portrait, « Revoir » sur
la ligne du titre, ligne « Leçon n · … » gardée en paysage. Barre haute
visible hors de l'écran de jeu en paysage (défaut antérieur à 1g), avec
un contrôle dans la recette automatique. Rappel des règles haut de
44 px ; « Aucune leçon terminée sur 5 · … » sur l'accueil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-12 13:55:17 +01:00
co-authored by Claude Opus 5.5
parent 5d0f7a9616
commit 351482fa9e
15 changed files with 147 additions and 49 deletions
+3 -1
View File
@@ -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
+3 -2
View File
@@ -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);
},
};
}
+3 -1
View File
@@ -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;
+4 -3
View File
@@ -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" });
+4 -2
View File
@@ -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",
});
+4 -2
View File
@@ -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()) {
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+3 -1
View File
@@ -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++;
}
+20 -3
View File
@@ -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");
+21
View File
@@ -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.
+73 -25
View File
@@ -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;