diff --git a/app/src/ui/app.ts b/app/src/ui/app.ts index 9425bf3..fb9559d 100644 --- a/app/src/ui/app.ts +++ b/app/src/ui/app.ts @@ -1553,10 +1553,7 @@ export function startApp(options: AppOptions): void { // relues à chaque visite, avec leurs analyses rangées. Celles qui n'en // ont pas encore sont analysées en arrière-plan, une à une, la plus // récente d'abord ; rien n'est recalculé pour les autres. - const progressionView = createProgressionView({ - section: ui.progression, - back: (link) => leavingLink(link, (route) => route.screen === "progression"), - }); + const progressionView = createProgressionView({ section: ui.progression }); let progressionGames: FinishedGame[] | null = null; // Chaque lecture a son numéro : une lecture dépassée n'est pas dessinée. let progressionRequest = 0; @@ -1589,10 +1586,14 @@ export function startApp(options: AppOptions): void { return games.filter((g): g is FinishedGame => g !== null); } - /** Ouvre l'écran : ce qui est déjà lu, puis les parties relues. */ + /** Ouvre l'écran : les parties relues à chaque visite (une partie a pu + * finir entre-temps) ; d'ici là, l'écran dit qu'il les lit, sans + * montrer la liste de la visite précédente comme si elle était à + * jour. */ function showProgression(): void { const request = ++progressionRequest; progressionTried.clear(); + progressionGames = null; renderProgression(); options.engineReady .then(() => loadProgression()) diff --git a/app/src/ui/home-model.ts b/app/src/ui/home-model.ts index 7b5c673..9243f14 100644 --- a/app/src/ui/home-model.ts +++ b/app/src/ui/home-model.ts @@ -30,6 +30,11 @@ export interface HomeModel { /** La carte de la partie : « Reprendre la partie » si une partie est en * cours, sinon « Nouvelle partie » en action principale. */ readonly resume: boolean; + /** La miniature du plateau et le compte de points : seulement pour une + * partie à reprendre. Sans partie en cours, la carte ne montre que son + * titre et « Nouvelle partie », jamais la position de la partie finie + * (recette 1h). */ + readonly board: boolean; /** L'invitation à installer. */ readonly installCard: boolean; } @@ -45,6 +50,7 @@ export function homeModel( return { welcome, resume: playing !== false, + board: playing !== false, installCard: !welcome && installCardShown(settings, installed), }; } diff --git a/app/src/ui/home-view.ts b/app/src/ui/home-view.ts index 62dbedf..03f0248 100644 --- a/app/src/ui/home-view.ts +++ b/app/src/ui/home-view.ts @@ -161,7 +161,7 @@ export function createHomeView(options: HomeViewOptions): HomeView { const mini = boardSvg(miniBox, "home-board", "home-board"); const facts = element(cardTop, "div", { className: "home-facts" }); const gameOverline = element(facts, "span", { className: "overline" }); - element(facts, "strong", { text: HOME_TEXTS.againstComputer }); + const againstLine = element(facts, "strong", { text: HOME_TEXTS.againstComputer }); const pips = element(facts, "span", { id: "home-pips", className: "home-pips" }); const actions = element(card, "div", { className: "home-actions" }); const resume = element(actions, "a", { @@ -243,7 +243,12 @@ export function createHomeView(options: HomeViewOptions): HomeView { return; } const sides = state.sides ?? OPENING_BOARDS; - drawBoard(mini, sides, miniBox.clientWidth || 132); + miniBox.hidden = !model.board; + pips.hidden = !model.board; + againstLine.hidden = !model.board; + if (model.board) { + drawBoard(mini, sides, miniBox.clientWidth || 132); + } gameOverline.textContent = model.resume ? HOME_TEXTS.gameOverline : HOME_TEXTS.noGameOverline; diff --git a/app/src/ui/progression-texts.ts b/app/src/ui/progression-texts.ts index 72ab817..0d2f6c3 100644 --- a/app/src/ui/progression-texts.ts +++ b/app/src/ui/progression-texts.ts @@ -11,14 +11,13 @@ import type { GameResult } from "../domain/game/state.js"; const plural = (n: number, one: string, many: string) => `${n} ${n > 1 ? many : one}`; const RESULT_KIND = { - single: "partie simple", + single: "", gammon: "gammon", backgammon: "backgammon", } as const; export const PROGRESSION_TEXTS = { title: "Votre progression", - back: "Retour", loading: "Lecture de vos parties…", failed: "Vos parties n'ont pas pu être lues.", /** Aucune partie terminée. */ @@ -30,22 +29,22 @@ export const PROGRESSION_TEXTS = { whatIsIt: "Qu'est-ce que le taux d'erreur ?", analysing: (n: number) => `Le maître analyse ${plural(n, "partie", "parties")} : ${n > 1 ? "elles apparaîtront" : "elle apparaîtra"} ici une fois ${n > 1 ? "analysées" : "analysée"}.`, - exercises: "Créer des exercices", + exercises: "Demander des exercices à une IA", listTitle: "Vos parties", - /** « Gagnée, gammon (2 points) ». */ + /** Le résultat court : « Gagnée, 1 point », « Perdue, gammon, 2 points » + * (jamais « partie simple »). */ result: (result: GameResult | null) => { if (result === null) { return "Partie terminée"; } const who = result.winner === "player" ? "Gagnée" : "Perdue"; - return `${who}, ${RESULT_KIND[result.kind]} (${plural(result.points, "point", "points")})`; + const kind = result.kind === "single" ? "" : `${RESULT_KIND[result.kind]}, `; + return `${who}, ${kind}${plural(result.points, "point", "points")}`; }, level: (name: string) => `contre ${name}`, rateLabel: "taux d'erreur", rateLoading: "en analyse", openSummary: (result: string, date: string) => `Bilan de la partie du ${date} : ${result}`, - analysis: "Analyse", - analysisLabel: (date: string) => `Analyse de la partie du ${date}`, /** La date d'une partie : « 3 nov. 2026 ». */ date: (ms: number) => new Date(ms).toLocaleDateString("fr-FR", { dateStyle: "medium" }), } as const; diff --git a/app/src/ui/progression-view.ts b/app/src/ui/progression-view.ts index 1495b45..60ada65 100644 --- a/app/src/ui/progression-view.ts +++ b/app/src/ui/progression-view.ts @@ -37,8 +37,6 @@ export interface ProgressionData { export interface ProgressionViewOptions { readonly section: HTMLElement; - /** Branche le lien « Retour » (il revient dans l'historique). */ - readonly back: (link: HTMLAnchorElement) => void; } export interface ProgressionView { @@ -47,12 +45,10 @@ export interface ProgressionView { } const SVG_NS = "http://www.w3.org/2000/svg"; -/** Hauteur de la courbe, et ses marges intérieures (étiquettes de l'axe à - * gauche, valeurs au-dessus des points). */ +/** Hauteur de la courbe, et ses marges intérieures (les valeurs au-dessus + * des points, le dernier point plus gros). */ const CURVE_HEIGHT = 168; -const PAD = { left: 34, right: 16, top: 24, bottom: 12 }; -/** Au-delà, les valeurs ne s'écrivent plus au-dessus des points. */ -const LABELLED_POINTS = 10; +const PAD = { left: 12, right: 12, top: 30, bottom: 12 }; function element( parent: HTMLElement, @@ -95,17 +91,11 @@ function levelName(entry: ProgressionEntry): string { export function createProgressionView(options: ProgressionViewOptions): ProgressionView { const { section } = options; section.replaceChildren(); - const head = element(section, "div", { className: "analysis-head" }); - const back = element(head, "a", { - id: "progression-back", - className: "button secondary", - text: PROGRESSION_TEXTS.back, - }); - back.href = "#/accueil"; - options.back(back); - element(head, "h2", { + // Pas de « Retour » (charte 1h) : la barre du haut et le retour du + // téléphone suffisent, comme sur la liste des leçons. + element(section, "h1", { id: "progression-title", - className: "analysis-title", + className: "progression-title", text: PROGRESSION_TEXTS.title, }); const summary = element(section, "p", { @@ -153,7 +143,9 @@ export function createProgressionView(options: ProgressionViewOptions): Progress } if (data.entries.length === 0) { summary.textContent = PROGRESSION_TEXTS.empty; - const actions = element(body, "div", { className: "progression-actions" }); + const actions = element(body, "div", { + className: "progression-actions progression-empty", + }); const start = element(actions, "a", { id: "progression-new-game", className: "button", @@ -164,10 +156,8 @@ export function createProgressionView(options: ProgressionViewOptions): Progress return; } const points = progressionCurve(data.entries); - summary.textContent = - data.phrases === null - ? "" - : progressionSentence(data.phrases, progressionTrend(points)); + const trend = progressionTrend(points); + summary.textContent = data.phrases === null ? "" : progressionSentence(data.phrases, trend); if (points.length >= CURVE_MIN_GAMES) { const card = element(body, "section", { @@ -190,7 +180,10 @@ export function createProgressionView(options: ProgressionViewOptions): Progress text: PROGRESSION_TEXTS.whatIsIt, }); link.href = routeHash({ screen: "glossary", term: "taux-d-erreur" }); - drawnCurve = () => drawCurve(svg, points); + // Le vert dit « mieux », jamais « dernier » : seulement si le + // taux baisse. + const better = trend.kind === "down"; + drawnCurve = () => drawCurve(svg, points, better); drawnCurve(); } @@ -203,7 +196,8 @@ export function createProgressionView(options: ProgressionViewOptions): Progress }).setAttribute("role", "status"); } - // « Créer des exercices » : la feuille du prompt, étape 1f ; bouton secondaire. + // « Demander des exercices à une IA » : la feuille du prompt, étape + // 1f ; bouton secondaire avec l'icône de copie. if (points.length > 0) { const actions = element(body, "div", { className: "progression-actions" }); const exercises = element(actions, "a", { @@ -229,13 +223,15 @@ export function createProgressionView(options: ProgressionViewOptions): Progress li.dataset.id = entry.id; const date = PROGRESSION_TEXTS.date(entry.startedAt); const result = PROGRESSION_TEXTS.result(entry.result); + // Une ligne, un seul lien : le bilan (l'analyse s'ouvre depuis + // lui), chevron à droite. const game = element(li, "a", { className: "progression-game" }); game.href = routeHash({ screen: "summary", game: entry.id }); game.setAttribute("aria-label", PROGRESSION_TEXTS.openSummary(result, date)); const text = element(game, "span", { className: "progression-text" }); element(text, "strong", { text: result }); element(text, "span", { - className: "muted", + className: "caption", text: `${date} · ${PROGRESSION_TEXTS.level(levelName(entry))}`, }); const rate = element(game, "span", { className: "progression-rate" }); @@ -247,12 +243,7 @@ export function createProgressionView(options: ProgressionViewOptions): Progress element(rate, "strong", { text: String(errorRateMillis(entry.errorRate.perDecision)) }); element(rate, "span", { className: "caption", text: PROGRESSION_TEXTS.rateLabel }); } - const analysis = element(li, "a", { - className: "button secondary progression-analysis", - text: PROGRESSION_TEXTS.analysis, - }); - analysis.href = routeHash({ screen: "analysis", game: entry.id, turn: null }); - analysis.setAttribute("aria-label", PROGRESSION_TEXTS.analysisLabel(date)); + game.append(icon("chevron")); return li; } @@ -260,58 +251,55 @@ export function createProgressionView(options: ProgressionViewOptions): Progress } /** La courbe : un point par partie analysée, de gauche (la plus ancienne) - * à droite ; l'axe va de 0 au plus haut taux, en millièmes ; le dernier - * point en verdict-good (charte). */ + * à droite, de 0 (la ligne du bas, seule ligne d'axe) au plus haut taux. Le + * dernier point est plus gros, à l'encre du texte, en verdict-good si le + * taux baisse (better) ; les valeurs, en millièmes, s'écrivent au-dessus du + * premier et du dernier point seulement (charte 1h). */ function drawCurve( svg: SVGSVGElement, points: readonly { readonly id: string; readonly perDecision: number }[], + better: boolean, ): void { const width = Math.round(svg.getBoundingClientRect().width || 360); const height = CURVE_HEIGHT; svg.setAttribute("viewBox", `0 0 ${width} ${height}`); svg.replaceChildren(); const millis = points.map((p) => errorRateMillis(p.perDecision)); - // Le haut de l'axe : le plus haut taux, arrondi à la dizaine au-dessus. - const top = Math.max(10, Math.ceil(Math.max(...millis) / 10) * 10); + const top = Math.max(1, ...millis); const inner = width - PAD.left - PAD.right; const step = points.length > 1 ? inner / (points.length - 1) : 0; const x = (i: number) => PAD.left + i * step; const y = (value: number) => PAD.top + (1 - value / top) * (height - PAD.top - PAD.bottom); - for (const level of [top, 0]) { - svgElement(svg, "line", { - x1: PAD.left, - x2: width - PAD.right, - y1: y(level), - y2: y(level), - class: "curve-axis", - }); - const label = svgElement(svg, "text", { - x: PAD.left - 6, - y: y(level) + 4, - class: "curve-label", - "text-anchor": "end", - }); - label.textContent = String(level); - } + svgElement(svg, "line", { + x1: PAD.left, + x2: width - PAD.right, + y1: y(0), + y2: y(0), + class: "curve-axis", + }); const line = points.map((p, i) => `${x(i)},${y(p.perDecision * 1000)}`).join(" "); svgElement(svg, "polyline", { points: line, class: "curve-line" }); + const lastIndex = points.length - 1; points.forEach((point, i) => { - const last = i === points.length - 1; + const last = i === lastIndex; + const cy = y(point.perDecision * 1000); const dot = svgElement(svg, "circle", { cx: x(i), - cy: y(point.perDecision * 1000), - r: last ? 6 : 4, - class: last ? "progression-dot last" : "progression-dot", + cy, + r: last ? 7 : 4, + class: last ? `progression-dot last${better ? " better" : ""}` : "progression-dot", "data-id": point.id, "data-rate": String(point.perDecision), }); dot.setAttribute("aria-hidden", "true"); - if (points.length <= LABELLED_POINTS) { + if (i === 0 || last) { const value = svgElement(svg, "text", { x: x(i), - y: y(point.perDecision * 1000) - 10, - class: last ? "progression-value last" : "progression-value", - "text-anchor": "middle", + y: cy - (last ? 14 : 11), + class: last + ? `progression-value last${better ? " better" : ""}` + : "progression-value", + "text-anchor": i === 0 && !last ? "start" : last && i > 0 ? "end" : "middle", }); value.textContent = String(millis[i]); } diff --git a/app/tests/home.test.ts b/app/tests/home.test.ts index b71364d..bc7e87d 100644 --- a/app/tests/home.test.ts +++ b/app/tests/home.test.ts @@ -8,6 +8,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; +import { homeModel } from "../src/ui/home-model.js"; import { installPlatform } from "../src/ui/install.js"; import { DEFAULT_SETTINGS, @@ -45,6 +46,20 @@ test("réglages d'une version précédente : les nouveaux champs prennent leur v assert.equal(parseSettings('{"tutorialSeen": -3, "welcomed": "oui"}').tutorialSeen, 0); }); +test("carte de la partie : sans partie en cours, ni miniature ni compte de la partie finie", () => { + const settings = { ...DEFAULT_SETTINGS, welcomed: true, gamesFinished: 1 }; + // Partie en cours, ou moteur qui se charge : la miniature et le compte. + assert.deepEqual( + [homeModel(settings, true, false).board, homeModel(settings, true, false).resume], + [true, true], + ); + assert.equal(homeModel(settings, null, false).board, true); + // Partie finie : le titre et « Nouvelle partie » seulement (recette 1h). + const over = homeModel(settings, false, false); + assert.equal(over.board, false); + assert.equal(over.resume, false); +}); + test("premier lancement : tant que rien n'a été fait, une seule fois", () => { assert.ok(isFirstLaunch(DEFAULT_SETTINGS)); // La partie commencée d'office au chargement ne compte pas. diff --git a/docs/adr/0013-position-dans-l-adresse.md b/docs/adr/0013-position-dans-l-adresse.md index 37c0113..16790d6 100644 --- a/docs/adr/0013-position-dans-l-adresse.md +++ b/docs/adr/0013-position-dans-l-adresse.md @@ -226,16 +226,17 @@ Ajoutées avec l'écran de progression (étape 1h), dans le même schéma : | Écran | Adresse | | --- | --- | | Progression : courbe du taux d'erreur et parties terminées | `#/progression` | -| Prompt « Créer des exercices », depuis la progression | `#/progression/ia` | +| Prompt « Demander des exercices à une IA », depuis la progression | `#/progression/ia` | - La ligne de progression de l'accueil est un lien vers `#/progression`. - L'écran est une page, comme l'analyse : « Retour » (et Échap, et le - retour du téléphone) revient à l'écran d'avant, l'accueil s'il a été - ouvert par son adresse. + L'écran est une page, comme la liste des leçons : pas de bouton + « Retour », la barre du haut, Échap et le retour du téléphone suffisent. + Échap revient à l'écran d'avant, l'accueil s'il a été ouvert par son + adresse. - Chaque partie de la liste est un lien vers son bilan - (`#/parties//bilan`) ; « Analyse » vers son analyse - (`#/parties//analyse`). Fermer le bilan ramène à la progression. -- « Créer des exercices » suit la règle des prompts (amendement du + (`#/parties//bilan`), d'où s'ouvre son analyse. Fermer le bilan + ramène à la progression. +- « Demander des exercices à une IA » suit la règle des prompts (amendement du 2026-11-10) : le bouton ouvre la feuille du prompt, dans sa propre feuille au-dessus de la progression (« ‹ Progression »), où le texte se relit avant d'être copié. Rechargée, l'adresse recalcule le prompt à diff --git a/docs/maitre/phrases.md b/docs/maitre/phrases.md index 1723b1c..edb5aa8 100644 --- a/docs/maitre/phrases.md +++ b/docs/maitre/phrases.md @@ -296,9 +296,10 @@ milieu, quand leur nombre est impair, ne compte dans aucune). ## progression.hausse -Le taux moyen remonte : la phrase reste encourageante, sans reproche. +Le taux moyen remonte : la phrase reste encourageante, sans reproche, sans +minimiser ni dramatiser. -- Sur vos {parties} dernières parties, votre taux d'erreur remonte un peu, de {debut} à {fin}. Les leçons sont là pour reprendre un point. +- Sur vos {parties} dernières parties, votre taux d'erreur remonte, de {debut} à {fin}. Les leçons sont là pour reprendre un point. ## progression.stable diff --git a/docs/prompts/README.md b/docs/prompts/README.md index 66e9ae8..cda9daf 100644 --- a/docs/prompts/README.md +++ b/docs/prompts/README.md @@ -23,7 +23,7 @@ d'après le cadrage, **à relire** par Pierre. | [videau.md](videau.md) | Expliquer cette décision de videau | Feuille du maître, bilan, analyse | | [bilan.md](bilan.md) | Bilan de ma partie | Bilan de fin de partie | | [concept.md](concept.md) | Expliquer ce concept | Fiche d'un terme du glossaire | -| [exercices.md](exercices.md) | Créer des exercices | Écran de progression (étape 1h) | +| [exercices.md](exercices.md) | Demander des exercices à une IA | Écran de progression (étape 1h) | Chaque prompt contient : diff --git a/docs/prompts/exercices.md b/docs/prompts/exercices.md index e5d2e89..e7bb0ae 100644 --- a/docs/prompts/exercices.md +++ b/docs/prompts/exercices.md @@ -1,6 +1,6 @@ # Créer des exercices -Le bouton « Créer des exercices » de l'écran de progression (étape 1h, +Le bouton « Demander des exercices à une IA » de l'écran de progression (étape 1h, `#/progression/ia`). Il rassemble les erreurs de vos dernières parties analysées (les 20 plus récentes au plus), et ce qui y revient. Texte sous licence CC BY-SA 4.0 (voir [README.md](README.md)). diff --git a/docs/scenarios/suivre-ma-progression.feature b/docs/scenarios/suivre-ma-progression.feature index 6d2eaa7..6d5e7e4 100644 --- a/docs/scenarios/suivre-ma-progression.feature +++ b/docs/scenarios/suivre-ma-progression.feature @@ -5,7 +5,7 @@ Fonctionnalité: Suivre ma progression votre taux d'erreur partie après partie : plus il est bas, mieux vous avez joué. Chaque partie terminée y figure avec son résultat, sa date, le niveau de l'ordinateur et son taux d'erreur, celui de son bilan ; touchez-la pour - rouvrir son bilan, ou son analyse. Une phrase du maître dit, en chiffres, + rouvrir son bilan, et de là son analyse. Une phrase du maître dit, en chiffres, comment évolue votre taux d'erreur. Scénario: Comment voir ma progression ? @@ -44,7 +44,7 @@ Fonctionnalité: Suivre ma progression Et les deux premières parties n'ont pas été analysées de nouveau Scénario: Comment créer des exercices sur mes erreurs ? - « Créer des exercices » prépare une question pour une IA : vos erreurs + « Demander des exercices à une IA » prépare une question : vos erreurs les plus coûteuses sur vos dernières parties, avec leur coût selon le maître, ce qui y revient (pions frappés, points faits par le coup du maître et pas par le vôtre), et la position de la plus coûteuse. Votre diff --git a/tests/browser/progression.mjs b/tests/browser/progression.mjs index a3a9463..7a9a074 100644 --- a/tests/browser/progression.mjs +++ b/tests/browser/progression.mjs @@ -7,14 +7,17 @@ // // Usage : node progression.mjs // Lancé par tests/browser.sh. L'écran sans partie terminée, puis trois -// fins de course courtes jouées par l'interface, depuis un lien de -// position, avec des dés imposés (comme bilan.mjs) : le joueur a 6-1 et -// deux pions, sur 6 et 1 ; il joue 6/5/off (grosse erreur, l'ordinateur -// double et le joueur passe), puis 6/off 1/off (gagne), puis de nouveau -// 6/5/off. Vérifie que la ligne de l'accueil mène à #/progression, que la -// courbe a trois points égaux, dans l'ordre, aux taux d'erreur des trois -// bilans, la phrase du maître, la liste reliée aux bilans et aux analyses, -// et « Créer des exercices » (feuille du prompt). Rien d'interactif sous +// courses courtes jouées jusqu'au bout par l'interface, depuis un lien de +// position (celle des tests du domaine), dés d'une graine fixe : le joueur +// joue toujours le premier pion jouable et la première case d'arrivée, et +// prend les doubles, ce qui donne plusieurs décisions par partie et des +// taux d'erreur réalistes. Vérifie que la ligne de l'accueil (44 px au +// moins) mène à #/progression, que la carte de la partie ne montre plus la +// partie finie, que la courbe a trois points égaux, dans l'ordre, aux taux +// d'erreur des trois bilans, le dernier point en vert seulement si le taux +// baisse, la phrase du maître, la liste (un lien par partie, vers son +// bilan) et « Demander des exercices à une IA » (feuille du prompt). Rien +// d'interactif sous // une barre d'action, aucune erreur ni violation de la politique de // sécurité. Captures (Chromium) dans /progression/, // en 412×915 et 915×412. @@ -35,9 +38,8 @@ mkdirSync(dir, { recursive: true }); const PORTRAIT = { width: 412, height: 915 }; const LANDSCAPE = { width: 915, height: 412 }; const SIZES = [PORTRAIT, LANDSCAPE]; -// Le joueur au trait : un pion sur 6, un sur 1 ; l'ordinateur : un sur 1, -// un sur 2 (positions de gnubg, chacun vu de lui-même). -const BEAROFF = "BQAACAIAAAAAAA"; +// Une course courte (Position ID de gnubg, app/tests/progression.test.ts). +const RACE = "trsXAADs7mwAAA"; const problems = []; const log = (text) => console.log(`[${browserName}] ${text}`); @@ -95,7 +97,6 @@ async function until(what, fn, arg, ms = 120_000) { throw new Error(`attente trop longue : ${what}`); } -const idle = () => until("écran libre", () => !document.body.classList.contains("busy")); /** Une capture de la recette dans les deux tailles (Chromium seulement), * après la règle commune ; settle replace l'écran après le changement de @@ -141,26 +142,49 @@ async function startFrom(position) { ); } -const place = (point) => (point === 0 ? "off-player" : `p${point}`); - -/** Lance les dés imposés et joue un coup, demi-coup par demi-coup. */ -async function play(dice, halfMoves) { - await page.evaluate((d) => { - window.__dice.push(...d); - }, dice); - await page.click("#roll"); - await idle(); - for (const [from, to] of halfMoves) { - await page.click(`#board .hit[data-place="${place(from)}"]`); - await idle(); - const land = page.locator(`#board .hit.can-land[data-place="${place(to)}"]`); - if ((await land.count()) > 0) { - await land.click(); - await idle(); +/** Joue la partie jusqu'au bout : lancer, le premier pion jouable et sa + * première case d'arrivée, valider ; prendre les doubles ; passer quand + * aucun coup n'est possible. */ +async function playOut() { + for (let step = 0; step < 2000; step++) { + const s = await page.evaluate(() => { + const shown = (id) => { + const el = document.getElementById(id); + return el !== null && !el.hidden && el.closest("[hidden]") === null && !el.disabled; + }; + return { + over: !document.getElementById("panel-veil").hidden || /\/bilan$/.test(location.hash), + busy: document.body.classList.contains("busy"), + take: shown("take"), + confirm: shown("confirm"), + skip: shown("skip"), + roll: shown("roll"), + land: document.querySelector("#board .hit.can-land") !== null, + move: document.querySelector("#board .hit.can-move") !== null, + }; + }); + if (s.over) { + return; + } + if (s.busy) { + await sleep(100); + } else if (s.take) { + await page.click("#take"); + } else if (s.confirm) { + await page.click("#confirm"); + } else if (s.skip) { + await page.click("#skip"); + } else if (s.land) { + await page.locator("#board .hit.can-land").first().click(); + } else if (s.move) { + await page.locator("#board .hit.can-move").first().click(); + } else if (s.roll) { + await page.click("#roll"); + } else { + await sleep(100); } } - check(!(await page.locator("#confirm").isHidden()), `coup incomplet : ${JSON.stringify(halfMoves)}`); - await page.click("#confirm"); + throw new Error("la partie ne finit pas"); } /** Le bilan de la partie qui vient de finir : son identifiant et son taux @@ -178,17 +202,10 @@ async function summaryRate() { })); } -/** Une partie : le joueur joue 6/5/off (puis passe le double) ou 6/off - * 1/off. */ -async function oneGame(mistake) { - await startFrom(BEAROFF); - if (mistake) { - await play([6, 1], [[6, 5], [5, 0]]); - await until("double de l'ordinateur", () => !document.getElementById("drop").hidden); - await page.click("#drop"); - } else { - await play([6, 1], [[6, 0], [1, 0]]); - } +/** Une course jouée jusqu'au bout, et le taux de son bilan. */ +async function oneGame() { + await startFrom(RACE); + await playOut(); return summaryRate(); } @@ -228,14 +245,13 @@ log("progression sans partie terminée : phrase et « Nouvelle partie »"); // ----------------------------------------------------- Trois parties jouées const games = []; -for (const mistake of [true, false, true]) { - games.push(await oneGame(mistake)); +for (let i = 0; i < 3; i++) { + games.push(await oneGame()); } check( games.every((g) => g.id && g.rate !== undefined && !Number.isNaN(Number(g.rate))), `bilans : ${JSON.stringify(games)}`, ); -check(Number(games[0].rate) > 0 && Number(games[1].rate) === 0, `taux des bilans : ${JSON.stringify(games)}`); log(`trois parties jouées, taux des bilans : ${games.map((g) => g.rate).join(", ")}`); // La ligne de l'accueil mène à la progression. @@ -251,9 +267,23 @@ await until("accueil", () => { }); const line = await page.evaluate(() => { const a = document.getElementById("home-progress"); - return { tag: a.tagName, href: a.getAttribute("href"), text: a.textContent }; + return { + tag: a.tagName, + href: a.getAttribute("href"), + text: a.textContent, + height: a.getBoundingClientRect().height, + // La carte de la partie, sans partie en cours : ni miniature ni + // compte de la partie finie (recette 1h). + mini: !document.querySelector("#home-game .home-mini").hidden, + pips: !document.getElementById("home-pips").hidden, + resume: !document.getElementById("home-resume").hidden, + }; }); -check(line.tag === "A" && line.href === "#/progression", `ligne de l'accueil : ${JSON.stringify(line)}`); +check( + line.tag === "A" && line.href === "#/progression" && line.height >= 44, + `ligne de l'accueil : ${JSON.stringify(line)}`, +); +check(!line.mini && !line.pips && !line.resume, `carte de la partie finie : ${JSON.stringify(line)}`); await capture("3-accueil-ligne", async () => { await page.locator("#home-progress").scrollIntoViewIfNeeded(); }); @@ -269,22 +299,36 @@ const shown = await page.evaluate(() => ({ id: c.getAttribute("data-id"), rate: c.getAttribute("data-rate"), last: c.classList.contains("last"), + better: c.classList.contains("better"), + r: Number(c.getAttribute("r")), })), + values: [...document.querySelectorAll("#progression-curve .progression-value")].length, + axisLabels: [...document.querySelectorAll("#progression-curve .curve-label")].length, items: [...document.querySelectorAll("#progression-list .progression-item")].map((li) => ({ id: li.dataset.id, summary: li.querySelector(".progression-game").getAttribute("href"), - analysis: li.querySelector(".progression-analysis").getAttribute("href"), + links: li.querySelectorAll("a, button").length, + chevron: li.querySelector(".progression-game > svg.icon") !== null, rate: li.querySelector(".progression-rate")?.dataset.rate, })), glossary: document.getElementById("progression-glossary")?.getAttribute("href"), exercises: document.getElementById("progression-exercises")?.getAttribute("href"), + exercisesText: document.getElementById("progression-exercises")?.textContent, + back: document.getElementById("progression-back") !== null, + title: document.getElementById("progression-title")?.tagName, })); check(shown.dots.length === 3, `courbe : ${shown.dots.length} points`); for (const [i, game] of games.entries()) { const dot = shown.dots[i]; check(dot.id === game.id && dot.rate === game.rate, `point ${i + 1} : ${JSON.stringify(dot)} contre ${JSON.stringify(game)}`); } -check(shown.dots[2].last && !shown.dots[0].last, "le dernier point n'est pas mis en avant"); +check(shown.dots[2].last && !shown.dots[0].last && shown.dots[2].r > shown.dots[0].r, + "le dernier point n'est pas plus gros"); +// Vert seulement si le taux baisse (progression.baisse). +const down = shown.summary.includes("vous faites moins d'erreurs"); +check(shown.dots[2].better === down && !shown.dots[0].better, `vert du dernier point : ${shown.summary}`); +check(shown.values === 2 && shown.axisLabels === 0, `valeurs de la courbe : ${shown.values}, axe : ${shown.axisLabels}`); +check(!shown.back && shown.title === "H1", `en-tête : ${JSON.stringify({ back: shown.back, title: shown.title })}`); check(shown.summary.startsWith("Sur vos 3 dernières parties"), `phrase : ${shown.summary}`); check(!shown.summary.includes("!"), `phrase : ${shown.summary}`); check(shown.items.length === 3 && shown.items[0].id === games[2].id, `liste : ${JSON.stringify(shown.items)}`); @@ -292,13 +336,17 @@ for (const item of shown.items) { const game = games.find((g) => g.id === item.id); check( item.summary === `#/parties/${item.id}/bilan` && - item.analysis === `#/parties/${item.id}/analyse` && + item.links === 1 && + item.chevron && item.rate === game.rate, `ligne : ${JSON.stringify(item)}`, ); } check(shown.glossary === "#/glossaire/taux-d-erreur", `lien du glossaire : ${shown.glossary}`); -check(shown.exercises === "#/progression/ia", `exercices : ${shown.exercises}`); +check( + shown.exercises === "#/progression/ia" && shown.exercisesText === "Demander des exercices à une IA", + `exercices : ${shown.exercises} « ${shown.exercisesText} »`, +); await capture("2-trois-parties"); log(`courbe : ${shown.dots.map((d) => d.rate).join(", ")} ; « ${shown.summary} »`); @@ -315,7 +363,7 @@ await page.click("#summary-close"); await progressionReady(); log("une ligne ouvre le bilan, la croix ramène à la progression"); -// « Créer des exercices » : la feuille du prompt. +// « Demander des exercices à une IA » : la feuille du prompt. await page.click("#progression-exercises"); await until( "prompt des exercices", @@ -326,11 +374,11 @@ await until( const prompt = await page.textContent("#prompt-text"); check(prompt.includes("Voici mes 3 dernières parties"), `prompt : ${prompt}`); check(prompt.includes("Ce qui revient dans ces erreurs"), `prompt : ${prompt}`); -check(prompt.includes("Je débute au backgammon."), `prompt : ${prompt}`); +check(/Je débute au backgammon\.|Je suis un joueur de niveau /.test(prompt), `prompt : ${prompt}`); check(!/[{}]/.test(prompt), `repère non rempli : ${prompt}`); await page.keyboard.press("Escape"); await until("retour à la progression", () => location.hash === "#/progression"); -log("« Créer des exercices » : prompt avec les erreurs et leurs thèmes"); +log("« Demander des exercices à une IA » : prompt avec les erreurs et leurs thèmes"); const csp = await page.evaluate(() => window.__csp); for (const violation of csp) { diff --git a/web/app.css b/web/app.css index fe5a1e3..7a765b1 100644 --- a/web/app.css +++ b/web/app.css @@ -3503,9 +3503,17 @@ html.landscape .summary-panel > .summary-end { .curve-band { fill: transparent; cursor: pointer; } .curve-band.selected { fill: var(--accent); fill-opacity: 0.08; } -/* La progression (#/progression, 1h) : la phrase du maître, la courbe du - * taux d'erreur (le dernier point en verdict-good), puis les parties. La - * page reprend l'en-tête et les cartes de l'analyse. */ +/* La progression (#/progression, 1h) : le titre, la phrase du maître, la + * courbe du taux d'erreur, puis les parties (charte, ecrans-etape-1.md, + * « 1h »). Les cartes reprennent celles de l'analyse. */ +.progression-title { + margin: var(--space-3) 0 var(--space-2); + font-size: 32px; + line-height: 36px; + font-weight: 800; + letter-spacing: -0.01em; +} + .progression-summary { margin: 0 0 var(--space-4); font-size: 17px; @@ -3523,10 +3531,13 @@ html.landscape .summary-panel > .summary-end { font-family: var(--font-sans); } +/* Le dernier point, plus gros, à l'encre du texte ; en verdict-good + * seulement si le taux baisse (le vert dit « mieux », jamais « dernier »). */ .progression-dot { fill: var(--text); } -.progression-dot.last { fill: var(--verdict-good); } -.progression-value { fill: var(--muted); font-size: 12px; } -.progression-value.last { fill: var(--verdict-good); font-weight: 700; } +.progression-dot.better { fill: var(--verdict-good); } +.progression-value { fill: var(--muted); font-size: 13px; } +.progression-value.last { fill: var(--text); font-weight: 700; } +.progression-value.better { fill: var(--verdict-good); } .progression .link-button { display: inline-flex; @@ -3539,6 +3550,13 @@ html.landscape .summary-panel > .summary-end { margin: 0 0 var(--space-4); } +/* Sans partie terminée : « Nouvelle partie », pleine largeur en portrait. */ +@media (orientation: portrait) { + .progression-empty .button { + flex: 1; + } +} + .progression-list { display: flex; flex-direction: column; @@ -3549,27 +3567,30 @@ html.landscape .summary-panel > .summary-end { } .progression-item { - display: flex; - gap: var(--space-2); - align-items: center; - padding: var(--space-2) var(--space-3); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); } -/* Toute la ligne ouvre le bilan de la partie. */ +/* Une ligne, un seul lien vers le bilan : résultat et date à gauche, taux + * en gras, puis le chevron. */ .progression-game { display: flex; - flex: 1; gap: var(--space-3); align-items: center; - min-width: 0; - min-height: 44px; + min-height: 56px; + padding: var(--space-2) var(--space-3); color: inherit; text-decoration: none; } +.progression-game > .icon { + flex: none; + width: 20px; + height: 20px; + color: var(--muted); +} + .progression-text { display: flex; flex: 1; @@ -3577,11 +3598,6 @@ html.landscape .summary-panel > .summary-end { min-width: 0; } -.progression-text .muted { - font-size: 15px; - line-height: 20px; -} - .progression-rate { display: flex; flex: none; @@ -3601,10 +3617,6 @@ html.landscape .summary-panel > .summary-end { color: var(--muted); } -.progression-analysis { - flex: none; -} - /* Le visualiseur : « ‹ Coup 7 sur 24 · 6-1 › », le plateau d'avant le * coup, la phrase du maître, votre coup contre celui du maître. */ .viewer-nav {