diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts
index d347ff2..7b902d8 100644
--- a/app/src/ui/app.ts
+++ b/app/src/ui/app.ts
@@ -1565,6 +1565,7 @@ export function startApp(options: AppOptions): void {
? TEXTS.unreadablePosition
: "";
ui.positionBoard.toggleAttribute("hidden", !shown);
+ ui.position.classList.toggle("with-board", shown);
ui.positionDetail.hidden = !shown;
ui.positionCopy.hidden = !shown;
ui.positionPlay.hidden = state.kind !== "shown" || state.shared.result !== null;
@@ -1572,11 +1573,20 @@ export function startApp(options: AppOptions): void {
return;
}
const shared = state.shared;
- const width = ui.positionBoard.clientWidth || Math.min(window.innerWidth, 640);
+ // En paysage, le plateau occupe toute la hauteur, à gauche de la
+ // colonne du titre et des actions (app.css), avec la géométrie du
+ // jeu : la colonne gauche du cadre garde le videau, sans menu ni
+ // bouton annuler (il ne se joue pas).
+ const landscape = layout() === "landscape";
+ const box = ui.positionBoard.getBoundingClientRect();
+ const width = box.width || Math.min(window.innerWidth, 640);
+ const height = box.height || window.innerHeight;
const model = boardModel(shared.sides);
renderBoard(
ui.positionBoard,
- boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
+ landscape
+ ? boardGeometry("landscape", width / height, height / 520)
+ : boardGeometry("portrait", PORTRAIT_ASPECT, width / 640),
model,
{
dice: [],
diff --git a/app/src/ui/help.ts b/app/src/ui/help.ts
index 3c6e1ba..2918c80 100644
--- a/app/src/ui/help.ts
+++ b/app/src/ui/help.ts
@@ -322,6 +322,8 @@ export function createHelpView(container: HTMLElement, tabs: HTMLElement): HelpV
const opened = questions.find((q) => q.id === question) ?? null;
const closed = route.screen === "help" && opened === null ? lastOpen : null;
lastOpen = opened;
+ // La barre haute de l'aide reste en vue (app.css) : ce qu'on
+ // amène en haut s'arrête 12 px sous elle (scroll-margin-top).
const target = opened?.section ?? (term === null ? undefined : terms.get(term)?.card);
if (target !== undefined) {
target.scrollIntoView({ block: "start" });
diff --git a/docs/etape-1.md b/docs/etape-1.md
index 8f669f3..c619913 100644
--- a/docs/etape-1.md
+++ b/docs/etape-1.md
@@ -20,7 +20,7 @@ Repris du cadrage (modules, écrans clés, IA générative en V1) et des ADR 001
| 1a. Moteur pour l'analyse (fait) | Niveaux de jeu (plis et bruit de GNU Backgammon : débutant à expert), classement des coups d'un jet avec équités, analyse d'un coup joué et d'une décision de videau, chance d'un jet ; exposés par `engine.h`, `engine-cli` et le Worker, avec positions de référence comparées à gnubg de bureau | — |
| 1b. Parties enregistrées (fait) | Stockage IndexedDB derrière l'interface de stockage existante : plusieurs parties, reprise automatique, branches (« Rejouer d'ici » crée une branche, l'originale reste intacte et rechargeable), export SGF avec variantes | — |
| 1c. Match et réglages | Partie libre ou match en N points, règle Crawford, règle Jacoby, choix du niveau de l'ordinateur, conseil du maître activable ou non ; écran de nouvelle partie | 1a, 1b |
-| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, gaffe) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
+| 1d. Conseil du maître | Après chaque coup du joueur, jamais avant (ADR 0011) : appréciation du moteur (bon coup, imprécision, erreur, grosse erreur) avec mot, icône et couleur, phrase du maître fondée sur les chiffres du moteur, coup recommandé montré sur demande ; aussi pour les décisions de videau ; calcul en arrière-plan sans retarder le jeu | 1a |
| 1e. Fin de partie et analyse | Bilan (ADR 0011) : résultat, coups par appréciation, taux d'erreur comparé à la chance, « le coup à revoir » ; écran d'analyse : graphe d'équité (toucher un point ouvre la position), liste des erreurs filtrable, visualiseur pas à pas coup joué contre meilleur coup, « Rejouer d'ici » | 1a, 1b, 1d |
| 1f. Prompts à copier | Gabarits versionnés et testés (`docs/prompts/` ou équivalent, source unique), bouton « Demander à une IA » sur le plateau, le conseil du maître, l'analyse et la fin de partie ; le moteur décide, l'IA explique | 1d, 1e |
| 1g. Accueil, tutoriel et leçons | Accueil (reprendre, nouvelle partie, prochaine leçon, progression résumée), tutoriel des règles interactif et illustré (0e.5), cinq premières leçons numérotées (ADR 0011) avec positions et « à vous de jouer », progression locale ; contenus CC BY-SA 4.0 en brouillon, à relire par un joueur fort | 0e.5, 1a |
diff --git a/docs/identite/charte/accueil-tutoriel-aide.md b/docs/identite/charte/accueil-tutoriel-aide.md
index e45e5fe..5fe146d 100644
--- a/docs/identite/charte/accueil-tutoriel-aide.md
+++ b/docs/identite/charte/accueil-tutoriel-aide.md
@@ -67,7 +67,8 @@ interrompre.
deux ou trois phrases. Une étape sur deux finit par un écran **« à vous »**
où le joueur fait le geste sur le vrai plateau, contrôlé par le moteur.
- En haut : une barre de progression en huit segments (`accent` pour les
- étapes faites) et « Passer » à droite. En bas : « Retour » et
+ étapes faites) et « Quitter » à droite (pas « Passer » : ce mot appartient au
+ videau, et le tutoriel enseigne les mots exacts du jeu). En bas : « Retour » et
« Continuer ».
- Les positions sont données par leurs identifiants gnubg (ADR 0013) ; le
plateau est le composant de l'app, non jouable sauf sur les écrans
@@ -113,9 +114,10 @@ interrompre.
flèche 6, un 1 un pion de la flèche 1. Touchez le pion, puis la sortie à
droite. » *À vous* : sortir deux pions avec 6-3.
7. **Gammon et backgammon.** « Une partie gagnée vaut 1 point. Si
- l'adversaire n'a sorti aucun pion quand vous finissez, c'est un gammon :
- 2 points. S'il lui reste en plus un pion sur la barre ou dans votre camp,
- c'est un backgammon : 3 points. »
+ l'adversaire n'a sorti aucun pion, c'est un gammon : 2 points ; s'il lui
+ reste en plus un pion sur la barre ou dans votre camp, c'est un
+ backgammon : 3 points. En partie libre, gammon et backgammon ne comptent
+ que si le videau a été doublé : c'est la règle de Jacoby. »
8. **Le videau.** « Le cube aux faces 2, 4, 8, 16, 32 et 64 sert à miser.
Avant de lancer vos dés, vous pouvez proposer de doubler la valeur de la
partie. L'ordinateur prend, et la partie continue en valant le double, ou il
diff --git a/docs/plans/2026-11-03-conseil-du-maitre.md b/docs/plans/2026-11-03-conseil-du-maitre.md
index 64dc46c..81fde3d 100644
--- a/docs/plans/2026-11-03-conseil-du-maitre.md
+++ b/docs/plans/2026-11-03-conseil-du-maitre.md
@@ -29,7 +29,7 @@ du moteur.
(événement d'analyse rétrocompatible, ou cache séparé justifié) pour servir
au bilan (1e) sans recalcul.
2. **Ligne du maître** : icône d'appréciation, mot en couleur (« Bon coup. »,
- « Imprécision. », « Erreur. », « Gaffe. » : correspondance avec les seuils
+ « Imprécision. », « Erreur. », « Grosse erreur. » (choix de Pierre du 2026-11-09, plutôt que « gaffe ») : correspondance avec les seuils
de gnubg documentée), phrase courte en italique, chevron. Toucher ouvre la
feuille d'explication : coup joué et meilleur coup montrés sur le plateau
(pions animés, ADR 0011 « comparaison visuelle »), écart d'équité seulement
diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs
index f3fb289..00fc3de 100644
--- a/tests/browser/play.mjs
+++ b/tests/browser/play.mjs
@@ -300,10 +300,49 @@ async function checkStable(when) {
stableChecks++;
}
+/** Les boutons visibles d'une page dont le texte passe à la ligne : un
+ * bouton tient sur une ligne et fait 48 px de haut (charte). Un bouton plus
+ * haut que 48 px n'est refusé que si son texte occupe plusieurs lignes ;
+ * un texte coupé par le bord (white-space: nowrap) est refusé aussi. */
+async function wrappedButtons(target) {
+ return target.evaluate(() => {
+ const wrapped = [];
+ for (const el of document.querySelectorAll("button, a.button")) {
+ const r = el.getBoundingClientRect();
+ if (el.closest("[hidden]") || r.width === 0 || r.height === 0) {
+ continue;
+ }
+ const name = el.id ? `#${el.id}` : el.textContent.trim();
+ const tops = new Set();
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
+ if (node.textContent.trim() === "") {
+ continue;
+ }
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ for (const line of range.getClientRects()) {
+ if (line.width > 0 && ![...tops].some((top) => Math.abs(top - line.top) < 4)) {
+ tops.add(line.top);
+ }
+ }
+ }
+ if (r.height > 48.5 && tops.size > 1) {
+ wrapped.push(`${name} sur ${tops.size} lignes (${Math.round(r.height)} px)`);
+ } else if (el.scrollWidth > el.clientWidth + 1) {
+ wrapped.push(`${name} coupé (${el.scrollWidth} px pour ${el.clientWidth})`);
+ }
+ }
+ return wrapped;
+ });
+}
+
/** Les critères de la recette vérifiables sur la vue courante. Renvoie des
* mesures pour le compte rendu. */
async function checkView(width, height, when) {
const where = `${width}x${height} (${when})`;
+ const wrapped = await wrappedButtons(page);
+ check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
const seen = await page.evaluate(() => {
const visible = (el) => {
if (el.closest("[hidden]")) {
@@ -767,6 +806,65 @@ async function checkAddresses() {
await expectScreen(tab, `capture ${name}`, expected);
await sleep(300);
await tab.screenshot({ path: `${dir}/${browserName}-${name}-${width}x${height}.png` });
+ const where = `${name} ${width}x${height}`;
+ const wrapped = await wrappedButtons(tab);
+ check(wrapped.length === 0, `${where} : boutons sur plusieurs lignes : ${wrapped.join(", ")}`);
+ if (name === "question" || name === "terme") {
+ // La barre haute de l'aide reste en vue, et ce qu'on a
+ // ouvert s'arrête juste sous elle.
+ const seen = await tab.evaluate(() => {
+ const bar = document.getElementById("help-bar").getBoundingClientRect();
+ const target = document.querySelector(
+ "#help-play .question.open, #help-glossary dt.current",
+ ).getBoundingClientRect();
+ return { scrolled: window.scrollY, barTop: bar.top, barBottom: bar.bottom, top: target.top };
+ });
+ check(seen.scrolled > 0 && Math.abs(seen.barTop) <= 1,
+ `${where} : barre de l'aide hors de la vue (${JSON.stringify(seen)})`);
+ check(Math.abs(seen.top - (seen.barBottom + 12)) <= 2,
+ `${where} : élément à ${seen.top} px, barre jusqu'à ${seen.barBottom} px`);
+ }
+ if (name === "position") {
+ // Les trois actions, dans l'ordre, visibles sans défiler.
+ const seen = await tab.evaluate(() => ({
+ scrolled: window.scrollY,
+ order: [...document.querySelectorAll(".position-actions > *")].map((el) => el.id),
+ boxes: ["position-play", "position-copy", "position-back"].map((id) => {
+ const r = document.getElementById(id).getBoundingClientRect();
+ return [r.left, r.top, r.right, r.bottom];
+ }),
+ board: (() => {
+ const r = document.getElementById("position-board").getBoundingClientRect();
+ return [r.left, r.top, r.width, r.height];
+ })(),
+ cube: (() => {
+ const r = document.querySelector("#position-board .cube-face")?.getBoundingClientRect();
+ return r === undefined ? null : [r.left, r.top, r.right, r.bottom];
+ })(),
+ controls: document.querySelectorAll("#position-board [data-action]").length,
+ }));
+ check(seen.order.join() === "position-play,position-copy,position-back",
+ `${where} : actions dans l'ordre ${seen.order}`);
+ const inView = seen.boxes.every(([l, t, r, b]) => l >= 0 && t >= 0 && r <= width && b <= height);
+ if (width > height) {
+ check(seen.scrolled === 0 && inView,
+ `${where} : actions hors de la vue sans défiler : ${JSON.stringify(seen.boxes)}`);
+ check(seen.board[0] === 0 && seen.board[1] === 0 && Math.abs(seen.board[3] - height) <= 1,
+ `${where} : plateau pas sur toute la hauteur à gauche : ${seen.board}`);
+ // Le videau, dans la colonne gauche du cadre, entier à
+ // l'écran ; ni menu ni annuler : la position ne se joue pas.
+ const [cl, ct, cr, cb] = seen.cube ?? [-1, -1, -1, -1];
+ check(seen.cube !== null && cl >= 0 && ct >= 0 && cr <= seen.board[2] && cb <= height,
+ `${where} : videau coupé : ${JSON.stringify(seen.cube)}`);
+ check(seen.controls === 0, `${where} : ${seen.controls} commandes sur le plateau`);
+ } else {
+ // Empilées sur toute la largeur.
+ const [first] = seen.boxes;
+ check(seen.boxes.every(([l, , r], i) => Math.abs(l - first[0]) <= 1 &&
+ Math.abs(r - first[2]) <= 1 && (i === 0 || seen.boxes[i - 1][3] <= seen.boxes[i][1])),
+ `${where} : actions pas empilées : ${JSON.stringify(seen.boxes)}`);
+ }
+ }
await tab.close();
}
}
diff --git a/web/app.css b/web/app.css
index fcc7352..ae94cf1 100644
--- a/web/app.css
+++ b/web/app.css
@@ -363,6 +363,8 @@ button {
border: 2px solid var(--accent);
border-radius: var(--radius-pill);
cursor: pointer;
+ /* Un bouton tient sur une ligne (charte). */
+ white-space: nowrap;
/* Pas de délai ni de zoom sur un double toucher. */
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
@@ -1291,7 +1293,7 @@ html.rotated .banner {
box-shadow: var(--shadow-raised);
}
-.glossary dt {
+.glossary .term-card {
padding-top: var(--space-3);
font-weight: 700;
}
@@ -1317,16 +1319,36 @@ html.rotated .banner {
-webkit-user-select: text;
}
-#help-back {
- margin-top: var(--space-4);
+/* La barre haute de l'aide (« Retour » et onglets) reste en vue quand la
+ * page défile. Sa hauteur est fixe (12 + 48 + 8 + 44 + 1 px de filet) : une
+ * question ou un terme ouvert par son adresse s'arrête 12 px sous elle. */
+.help {
+ --help-bar-height: 113px;
}
-/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique. */
+.help-bar {
+ position: sticky;
+ top: env(safe-area-inset-top);
+ z-index: 3;
+ height: var(--help-bar-height);
+ margin-inline: calc(-1 * var(--space-4));
+ padding: var(--space-3) var(--space-4) 0;
+ background: var(--background);
+}
+
+.help .question,
+.glossary .term-card {
+ scroll-margin-top: calc(var(--help-bar-height) + 12px + env(safe-area-inset-top));
+}
+
+/* Les onglets de l'aide : des liens, l'onglet courant souligné en brique ;
+ * sur une ligne, qui défile si l'écran est trop étroit. */
.help-tabs {
display: flex;
- flex-wrap: wrap;
gap: var(--space-1);
- margin-top: var(--space-4);
+ overflow-x: auto;
+ white-space: nowrap;
+ margin-top: var(--space-2);
border-bottom: 1px solid var(--border);
}
@@ -1402,6 +1424,12 @@ html.rotated .banner {
margin-inline: calc(-1 * var(--space-4));
padding-inline: var(--space-4);
background: var(--accent-soft);
+ /* Le surlignage montre où l'on arrive, puis s'efface après 2 s. */
+ animation: term-fade var(--duration-moderate) var(--easing-exit) 2s forwards;
+}
+
+@keyframes term-fade {
+ to { background-color: transparent; }
}
/* -------------------------------------------------- Liens et boutons */
@@ -1420,6 +1448,7 @@ a.button {
background: var(--accent);
border: 2px solid var(--accent);
border-radius: var(--radius-pill);
+ white-space: nowrap;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
@@ -1483,14 +1512,6 @@ a.link-button {
margin: var(--space-2) 0;
}
-/* En paysage, le plateau tient dans la hauteur de l'écran. */
-html.landscape .position-board {
- width: min(100%, calc(80vh * 640 / 520));
- height: auto;
- margin-inline: auto;
- aspect-ratio: 640 / 520;
-}
-
.position-detail {
margin: var(--space-2) 0 var(--space-4);
font-size: 15px;
@@ -1498,26 +1519,25 @@ html.landscape .position-board {
color: var(--muted);
}
+/* Les actions empilées sur toute la largeur, la principale en premier. */
.position-actions {
- display: grid;
- grid-template-columns: 1fr 1.6fr;
+ display: flex;
+ flex-direction: column;
gap: var(--space-2);
}
.position-actions > * {
- min-width: 0;
+ width: 100%;
}
-.position-copy {
+#position-copy {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
- width: 100%;
- margin-top: var(--space-3);
}
-.position-copy .icon {
+#position-copy .icon {
width: 20px;
height: 20px;
}
@@ -2122,3 +2142,48 @@ html.landscape .welcome {
padding-inline: 0;
background: none;
}
+
+/* En paysage, comme l'écran de jeu : le plateau à gauche sur toute la
+ * hauteur (colonne gauche du cadre avec le videau seul), le titre, le contexte et les actions
+ * dans une colonne à droite. */
+html.landscape .position-view.with-board {
+ position: fixed;
+ inset: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 288px;
+ grid-template-rows: auto auto auto auto 1fr;
+ max-width: none;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ background: var(--background);
+}
+
+html.landscape .position-view.with-board > * {
+ grid-column: 2;
+ margin-inline: 0;
+ padding-inline: var(--space-4) calc(var(--space-4) + env(safe-area-inset-right));
+}
+
+html.landscape .position-view.with-board .position-title {
+ margin-top: var(--space-4);
+}
+
+html.landscape .position-view.with-board .position-board {
+ grid-row: 1 / -1;
+ grid-column: 1;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ aspect-ratio: auto;
+ background: var(--frame);
+}
+
+html.landscape .position-view.with-board .position-actions {
+ padding-bottom: var(--space-4);
+}
+
+html.landscape .position-view.with-board .position-actions > * {
+ padding-inline: 16px;
+}
diff --git a/web/index.html b/web/index.html
index dcd8aa9..27c4e72 100644
--- a/web/index.html
+++ b/web/index.html
@@ -91,27 +91,31 @@
+ jouable. En portrait, de haut en bas ; en paysage, le plateau à gauche
+ sur toute la hauteur, le reste dans une colonne à droite. -->