From 0a88e835d2fba0776a5e8c3af3b6e4e1e51cac91 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 9 Nov 2026 18:37:29 +0100 Subject: [PATCH 1/3] =?UTF-8?q?Vocabulaire=20:=20=C2=AB=20Grosse=20erreur?= =?UTF-8?q?=20=C2=BB=20plut=C3=B4t=20que=20=C2=AB=20gaffe=20=C2=BB=20(choi?= =?UTF-8?q?x=20de=20Pierre)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/etape-1.md | 2 +- docs/plans/2026-11-03-conseil-du-maitre.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 From e724368f0922874cea385cc892c0d3d8cd2ae0c2 Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 9 Nov 2026 18:38:53 +0100 Subject: [PATCH 2/3] =?UTF-8?q?Charte=20:=20tutoriel,=20=C3=A9tape=207=20a?= =?UTF-8?q?vec=20la=20r=C3=A8gle=20de=20Jacoby,=20lien=20=C2=AB=20Quitter?= =?UTF-8?q?=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/identite/charte/accueil-tutoriel-aide.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 From 2b8fa1a0b05ed15a6d80dc894eee47559b29437e Mon Sep 17 00:00:00 2001 From: Pierre MARTIN Date: Mon, 9 Nov 2026 18:52:50 +0100 Subject: [PATCH 3/3] =?UTF-8?q?Recette=20design=20des=20adresses=20:=20bar?= =?UTF-8?q?re=20de=20l'aide=20coll=C3=A9e,=20boutons=20sur=20une=20ligne,?= =?UTF-8?q?=20position=20en=20paysage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Barre haute de l'aide en position sticky ; question ou terme ouvert par son adresse arrêté 12 px sous elle ; surlignage du terme effacé après 2 s. Boutons en white-space: nowrap ; actions de la position empilées (Jouer, Copier, Retour). En paysage, plateau à gauche sur toute la hauteur avec la géométrie du jeu (videau entier, sans menu ni annuler), colonne de 288 px à droite. Test navigateur : aucun bouton sur deux lignes, barre de l'aide en vue, actions visibles sans défiler, videau entier. Co-Authored-By: Claude Opus 5.5 --- app/src/ui/app.ts | 14 +++++- app/src/ui/help.ts | 2 + tests/browser/play.mjs | 98 ++++++++++++++++++++++++++++++++++++++ web/app.css | 105 +++++++++++++++++++++++++++++++++-------- web/index.html | 22 +++++---- 5 files changed, 210 insertions(+), 31 deletions(-) diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index b71bf6f..869c329 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -1264,6 +1264,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; @@ -1271,11 +1272,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 bbca105..6a3ca07 100644 --- a/app/src/ui/help.ts +++ b/app/src/ui/help.ts @@ -231,6 +231,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)); if (target !== undefined) { target.scrollIntoView({ block: "start" }); diff --git a/tests/browser/play.mjs b/tests/browser/play.mjs index 3b9d3c7..c43392b 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]")) { @@ -762,6 +801,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 11a94cd..bebd591 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; @@ -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 dt { + 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,70 @@ 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; } + +/* 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 43dae8d..90d97db 100644 --- a/web/index.html +++ b/web/index.html @@ -84,27 +84,31 @@ + jouable. En portrait, de haut en bas ; en paysage, le plateau à gauche + sur toute la hauteur, le reste dans une colonne à droite. -->