Compare commits

...
5 Commits
Author SHA1 Message Date
Pierre MARTINandClaude Opus 5.5 06cc20fb1b Recette 1h : « aucune gagnée », résultat avec gammon et videau
Le résultat d'une partie dit d'où viennent ses points (« Perdue,
gammon, videau à 2, 4 points ») ; la valeur du videau vient du journal
rejoué. Les « Perdue, 2 points » des captures étaient des parties
simples perdues avec un videau à 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:10:11 +01:00
Pierre MARTINandClaude Opus 5.5 c2c5d13651 Recette 1h : courbe, lignes de partie, en-tête, bouton des exercices, carte de l'accueil
Dernier point plus gros, vert seulement si le taux baisse ; valeurs au
premier et au dernier point, axe réduit à la ligne du 0. Une ligne de
partie = un lien vers le bilan, résultat court, date et niveau en
caption. Plus de « Retour », titre en h1 ; « Nouvelle partie » pleine
largeur en portrait ; « Demander des exercices à une IA ». La carte de
l'accueil sans partie en cours ne montre plus la partie finie (testé).
Phrase progression.hausse sans « un peu ». L'écran relit ses parties à
chaque visite sans montrer celles de la précédente. Test navigateur sur
trois courses jouées jusqu'au bout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 20:02:18 +01:00
Pierre MARTIN 2664486541 Merge remote-tracking branch 'origin/main' into progression 2026-11-12 19:09:07 +01:00
pierre 2d787b2c0b Merge pull request 'Charte de la progression : retours de la recette 1h' (#64) from charte-progression into main 2026-11-12 18:08:37 +00:00
Pierre MARTINandClaude Opus 5.5 368d3c9e8f Charte de la progression : point final vert seulement en baisse, lignes de partie en un lien, phrase de hausse sans « un peu »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 19:04:05 +01:00
17 changed files with 301 additions and 176 deletions
+5 -2
View File
@@ -12,8 +12,8 @@
// n'a pas encore de taux ; l'application l'analyse en arrière-plan.
import type { Level } from "../game/engine.js";
import type { GameJournal } from "../game/events.js";
import type { GameResult } from "../game/state.js";
import { currentLine, type GameJournal } from "../game/events.js";
import { type GameResult, replay } from "../game/state.js";
import { type ErrorRate, readyForSummary, summarizeAnalysis } from "./game-analysis.js";
/** Les parties prises en compte au plus : les plus récentes. Au-delà, la
@@ -43,6 +43,8 @@ export interface ProgressionEntry {
readonly startedAt: number;
readonly level: Level;
readonly result: GameResult | null;
/** La valeur du videau à la fin de la partie (1 : jamais doublé). */
readonly cube: number;
readonly errorRate: ErrorRate | null;
}
@@ -56,6 +58,7 @@ export function progressionEntries(games: readonly FinishedGame[]): ProgressionE
startedAt: game.startedAt,
level: game.level,
result: game.result,
cube: replay(currentLine(game.journal)).cube.value,
errorRate: readyForSummary(game.journal)
? summarizeAnalysis(game.journal).player.errorRate
: null,
+6 -5
View File
@@ -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())
+6
View File
@@ -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),
};
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const HOME_TEXTS = {
progress: (played: number, won: number) =>
played === 0
? "aucune partie jouée"
: `${plural(played, "partie jouée", "parties jouées")} · ${plural(won, "gagnée", "gagnées")}`,
: `${plural(played, "partie jouée", "parties jouées")} · ${won === 0 ? "aucune gagnée" : plural(won, "gagnée", "gagnées")}`,
/** La ligne de progression, lien vers l'écran de progression (1h). */
progressLink: (line: string) => `Votre progression : ${line}`,
footer: "Né à l'Atelier du Huit, à Cugnaux. Logiciel libre, sans publicité ni mise.",
+7 -2
View File
@@ -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;
+17 -9
View File
@@ -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,31 @@ 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) ». */
result: (result: GameResult | null) => {
/** Le résultat court, qui dit d'où viennent les points : « Gagnée,
* 1 point », « Perdue, gammon, 2 points », « Perdue, videau à 2,
* 2 points », « Perdue, gammon, videau à 2, 4 points » (jamais
* « partie simple »). cube : la valeur du videau à la fin de la
* partie. */
result: (result: GameResult | null, cube: number) => {
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 parts = [result.winner === "player" ? "Gagnée" : "Perdue"];
if (result.kind !== "single") {
parts.push(RESULT_KIND[result.kind]);
}
if (cube > 1) {
parts.push(`videau à ${cube}`);
}
parts.push(plural(result.points, "point", "points"));
return parts.join(", ");
},
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;
+49 -60
View File
@@ -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<K extends keyof HTMLElementTagNameMap>(
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", {
@@ -135,6 +125,7 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
entries: data.entries.map((e) => [
e.id,
e.result,
e.cube,
e.level,
e.errorRate?.perDecision ?? null,
]),
@@ -153,7 +144,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 +157,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 +181,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 +197,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", {
@@ -228,14 +223,16 @@ export function createProgressionView(options: ProgressionViewOptions): Progress
li.className = "progression-item";
li.dataset.id = entry.id;
const date = PROGRESSION_TEXTS.date(entry.startedAt);
const result = PROGRESSION_TEXTS.result(entry.result);
const result = PROGRESSION_TEXTS.result(entry.result, entry.cube);
// 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 +244,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 +252,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]);
}
+15
View File
@@ -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.
+21
View File
@@ -38,6 +38,8 @@ import {
mistakeThemes,
} from "../src/domain/prompts/prompts.js";
import { PROMPT_FILES, parsePromptTemplate } from "../src/domain/prompts/templates.js";
import { HOME_TEXTS } from "../src/ui/home-texts.js";
import { PROGRESSION_TEXTS } from "../src/ui/progression-texts.js";
import { parseRoute, routeHash } from "../src/ui/router.js";
import { loadPhrases } from "./support/advice.js";
import { loadNodeEngine } from "./support/node-engine.js";
@@ -86,6 +88,25 @@ test("l'adresse #/progression et celle de « Créer des exercices »", () => {
assert.equal(routeHash({ screen: "progression", ai: true }), "#/progression/ia");
});
test("le résultat d'une partie dit d'où viennent les points : gammon, videau", () => {
const result = PROGRESSION_TEXTS.result;
const lost = (kind: "single" | "gammon" | "backgammon", points: number) =>
({ winner: "computer", kind, points }) as const;
assert.equal(result({ winner: "player", kind: "single", points: 1 }, 1), "Gagnée, 1 point");
assert.equal(result(lost("single", 1), 1), "Perdue, 1 point");
assert.equal(result(lost("gammon", 2), 1), "Perdue, gammon, 2 points");
assert.equal(result(lost("single", 2), 2), "Perdue, videau à 2, 2 points");
assert.equal(result(lost("gammon", 4), 2), "Perdue, gammon, videau à 2, 4 points");
assert.equal(result(lost("backgammon", 12), 4), "Perdue, backgammon, videau à 4, 12 points");
assert.equal(result(null, 1), "Partie terminée");
});
test("la ligne de l'accueil : « aucune gagnée », comme « aucune leçon terminée »", () => {
assert.equal(HOME_TEXTS.progress(3, 0), "3 parties jouées · aucune gagnée");
assert.equal(HOME_TEXTS.progress(3, 1), "3 parties jouées · 1 gagnée");
assert.equal(HOME_TEXTS.progress(0, 0), "aucune partie jouée");
});
test("le taux d'erreur s'écrit en millièmes, comme les mEMG de GNU Backgammon", () => {
assert.equal(errorRateMillis(0.0142), 14);
assert.equal(errorRateMillis(0.0075), 8);
+8 -7
View File
@@ -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/<id>/bilan`) ; « Analyse » vers son analyse
(`#/parties/<id>/analyse`). Fermer le bilan ramène à la progression.
- « Créer des exercices » suit la règle des prompts (amendement du
(`#/parties/<id>/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 à
+21 -7
View File
@@ -255,14 +255,28 @@ Maquette « Progression ».
- Une phrase de synthèse, factuelle (« Sur vos 8 dernières parties, vous
faites moins d'erreurs : votre taux d'erreur est passé de 14 à 7. »). Si la
tendance monte, la phrase reste encourageante et sans reproche (« Votre
taux d'erreur remonte un peu ces derniers jours. Les leçons sont là
pour reprendre un point. »).
tendance monte, la phrase reste encourageante et sans reproche, sans
minimiser ni dramatiser (« Sur vos 8 dernières parties, votre taux
d'erreur remonte, de 7 à 12. Les leçons sont là pour reprendre un
point. »).
- La courbe du taux d'erreur par partie terminée, avec le rappel « plus bas,
c'est mieux » dans son titre, le dernier point en `verdict-good`, et un lien
vers la fiche « taux d'erreur ».
- La liste des parties (résultat, date, niveau de l'ordinateur, taux d'erreur
en chiffre gras aligné à droite), chacune ouvrant son bilan.
c'est mieux » dans son titre et un lien vers la fiche « taux d'erreur ».
Le dernier point est plus gros, à l'encre du texte ; il ne passe en
`verdict-good` que si la tendance baisse (le vert dit « mieux », jamais
« dernier »). Les valeurs s'écrivent au-dessus du premier et du dernier
point seulement ; l'axe ne porte que la ligne du 0, sans chiffres qui
doublent ceux des points.
- La liste des parties : chaque ligne est un seul lien vers son bilan
(chevron à droite), sans bouton par ligne ; l'analyse s'ouvre depuis le
bilan. Sur la ligne : le résultat court (« Gagnée, 1 point », « Perdue,
gammon, 2 points », jamais « partie simple »), puis en `caption`
`text-muted` la date et le niveau (« 12 nov. 2026 · contre
Intermédiaire », le niveau sans couleur), et à droite le taux d'erreur en
chiffre gras.
- Sans partie terminée : une phrase et « Nouvelle partie » en action
principale, pleine largeur en portrait.
- Pas de bouton « Retour » dans l'en-tête : la barre du haut et le retour du
téléphone suffisent, comme sur la liste des leçons.
- Moins de trois parties analysées : pas de courbe, une phrase « Votre
courbe apparaîtra après trois parties. ».
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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 :
+1 -1
View File
@@ -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)).
+2 -2
View File
@@ -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
+103 -54
View File
@@ -7,14 +7,17 @@
//
// Usage : node progression.mjs <adresse du site> <chromium|firefox> <dossier des captures>
// 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 <dossier des captures>/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,37 @@ 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,
result: li.querySelector(".progression-text strong").textContent,
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,15 +337,19 @@ 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} »`);
log(`courbe : ${shown.dots.map((d) => d.rate).join(", ")} ; « ${shown.summary} » ; résultats : ${shown.items.map((i) => i.result).join(" | ")}`);
// Une ligne ouvre le bilan ; le fermer ramène à la progression.
await page.click(`#progression-list .progression-item[data-id="${games[0].id}"] .progression-game`);
@@ -315,7 +364,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 +375,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) {
+35 -23
View File
@@ -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 {