Demander à une IA : une seule feuille à la fois, la copie confirmée sur le bouton

Recette de 1f (F1, F2). Depuis la feuille du maître ou le bilan, la page
du prompt prend la place de leur contenu, à la même hauteur, avec
« ‹ Explication » ou « ‹ Bilan » pour revenir ; depuis l'analyse et le
glossaire, elle monte dans sa propre feuille. La croix et « Fermer »
ferment la feuille. Plus de bandeau « Prompt copié » : le bouton passe à
« ✓ Copié » (verdict-good) 2 s. Test navigateur : une poignée, une croix,
un titre, même hauteur, retour du téléphone, couleur du bouton. Charte et
amendement de l'ADR 0013 mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Pierre MARTIN
2026-11-11 21:54:32 +01:00
co-authored by Claude Opus 5.5
parent f2df1b2e7f
commit d5f08fe393
10 changed files with 438 additions and 193 deletions
+174 -73
View File
@@ -11,11 +11,14 @@
// l'ordinateur double, le joueur passe. Vérifie le bouton « Demander à une
// IA » dans la feuille du maître, sur le bilan, dans l'analyse et sur une
// fiche du glossaire ; la feuille du prompt à son adresse (…/ia), lisible
// avant la copie, rechargeable ; la copie (dans Chromium, le presse-papiers
// est relu et comparé au texte de la feuille, et aux chiffres de la feuille
// du maître) ; le bandeau « Prompt copié » ; la fermeture qui ramène à
// l'écran d'en dessous ; aucune requête vers un autre site ; l'absence
// d'erreur et de violation de la politique de sécurité. Captures (Chromium)
// avant la copie, rechargeable ; une seule feuille à la fois (depuis la
// feuille du maître ou le bilan, la page du prompt prend leur place, à la
// même hauteur, avec « ‹ Explication » ou « ‹ Bilan ») ; la copie (dans
// Chromium, le presse-papiers est relu et comparé au texte de la feuille, et
// aux chiffres de la feuille du maître), confirmée sur le bouton (« Copié »,
// 2 s) ; le retour (« ‹ … », Échap, retour du téléphone) qui ramène à
// l'écran d'avant, la croix qui ferme ; aucune requête vers un autre site ;
// l'absence d'erreur et de violation de la politique de sécurité. Captures (Chromium)
// dans <dossier des captures>/prompts/, en 412×915 et 915×412.
import { mkdirSync } from "node:fs";
@@ -109,20 +112,15 @@ const idle = () => until("écran libre", () => !document.body.classList.contains
/** Une capture de la recette dans les deux tailles (Chromium seulement),
* après la règle commune : rien d'interactif sous une barre d'action, pas
* de bouton étiré. before() est rejoué avant chaque taille (le bandeau
* « Prompt copié » ne dure que 2 s). */
* de bouton étiré. before() est rejoué avant chaque taille (« Copié » ne
* dure que 2 s sur le bouton). */
async function capture(name, before = null) {
if (!full) {
return;
}
// Le bandeau « Prompt copié » d'une copie précédente est parti (sauf
// pour sa propre capture, où before le fait réapparaître).
await until(
"bandeau parti",
() => document.getElementById("prompt-banner").hidden,
null,
10_000,
);
// « Copié » d'une copie précédente est reparti (sauf pour sa propre
// capture, où before le fait réapparaître).
await until("bouton revenu", notCopied, null, 10_000);
for (const size of SIZES) {
await page.setViewportSize(size);
await sleep(300);
@@ -141,26 +139,71 @@ async function capture(name, before = null) {
await sleep(300);
}
/** La feuille du prompt, prête : son texte. */
/** Le bouton de copie n'est pas (ou plus) en « Copié ». */
const notCopied = () => {
const copy = document.getElementById("prompt-copy");
return copy === null || !copy.classList.contains("copied");
};
/** La page du prompt, prête et visible : son texte. */
async function openPrompt(button, hashEnd) {
await page.locator(button).scrollIntoViewIfNeeded();
await page.click(button);
await until(
"feuille du prompt prête",
(end) =>
location.hash.endsWith(end) &&
!document.getElementById("prompt-veil").hidden &&
document.getElementById("prompt-sheet")?.dataset.ready === "true",
(end) => {
const prompt = document.getElementById("prompt-page");
return (
location.hash.endsWith(end) &&
prompt?.dataset.ready === "true" &&
prompt.getClientRects().length > 0
);
},
hashEnd,
);
return page.textContent("#prompt-text");
}
/** Une seule feuille visible : la page du prompt, dans la feuille hostId
* (feuille du maître, bilan) ou dans la sienne (null) ; une poignée, une
* croix et un titre visibles au plus ; « ‹ back ». */
async function checkOneSheet(hostId, back, what) {
const seen = await page.evaluate((host) => {
const visible = (el) =>
el.getClientRects().length > 0 && getComputedStyle(el).visibility === "visible";
const count = (selector) => [...document.querySelectorAll(selector)].filter(visible).length;
const prompt = document.getElementById("prompt-page");
return {
parent: prompt.parentElement.id,
ownSheet: !document.getElementById("prompt-veil").hidden,
grips: count(".grip"),
closes: count('[role="dialog"] .icon-button'),
titles: count('[role="dialog"] h2'),
back: document.getElementById("prompt-back").textContent,
hostVisible: host === null || !document.getElementById(host).parentElement.hidden,
};
}, hostId);
check(seen.parent === (hostId ?? "prompt-sheet"), `${what} : page du prompt dans ${seen.parent}`);
check(seen.ownSheet === (hostId === null), `${what} : feuille propre ${seen.ownSheet}`);
check(seen.hostVisible, `${what} : la feuille d'origine n'est pas affichée`);
check(seen.grips <= 1, `${what} : ${seen.grips} poignées visibles`);
check(seen.closes === 1, `${what} : ${seen.closes} croix visibles`);
check(seen.titles === 1, `${what} : ${seen.titles} titres visibles`);
check(seen.back === `‹ ${back}`, `${what} : retour « ${seen.back} »`);
}
/** La hauteur et le haut d'un élément. */
const boxOf = (selector) =>
page.evaluate((s) => {
const r = document.querySelector(s).getBoundingClientRect();
return { top: Math.round(r.top), height: Math.round(r.height) };
}, selector);
/** Ce que tout prompt doit contenir (cadrage, « IA générative en V1 »). */
function checkPrompt(text, what) {
for (const part of [
"Tu es un coach de backgammon",
"Je suis un joueur de niveau ",
"Je débute au backgammon.",
"Position ID : ",
"Match ID : ",
"https://ustagammon.sans.pub/#/position/",
@@ -174,42 +217,82 @@ function checkPrompt(text, what) {
check(!/\{[a-z_]+\}/.test(text) && !text.includes("undefined"), `${what} : repère vide`);
}
/** Copie le prompt, attend le bandeau, et (Chromium) relit le
* presse-papiers. */
/** La copie, confirmée sur le bouton : « Copié », coche, verdict-good. */
const copiedOnButton = () => {
const copy = document.getElementById("prompt-copy");
return copy.classList.contains("copied") && copy.textContent === "Copié";
};
/** Copie faite (« Copié ») ou repli (le message sous le texte). */
const copyDone = () => {
const copy = document.getElementById("prompt-copy");
return (
copy.classList.contains("copied") ||
document.getElementById("prompt-status").textContent !== ""
);
};
/** Copie le prompt, attend « Copié » sur le bouton puis son retour à
* « Copier le prompt » après 2 s, et (Chromium) relit le presse-papiers. */
async function copyPrompt(what) {
await page.click("#prompt-copy");
await until(
"copie faite",
() =>
!document.getElementById("prompt-banner").hidden ||
document.getElementById("prompt-status").textContent !== "",
null,
10_000,
);
const shown = await page.evaluate(() => ({
banner: !document.getElementById("prompt-banner").hidden,
bannerText: document.getElementById("prompt-banner").textContent,
status: document.getElementById("prompt-status").textContent,
text: document.getElementById("prompt-text").textContent,
}));
await until("copie faite", copyDone, null, 10_000);
const shown = await page.evaluate(() => {
const copy = document.getElementById("prompt-copy");
return {
copied: copy.classList.contains("copied"),
label: copy.textContent,
check: copy.querySelector("svg.icon") !== null,
color: getComputedStyle(copy).color,
good: getComputedStyle(document.documentElement)
.getPropertyValue("--verdict-good")
.trim(),
status: document.getElementById("prompt-status").textContent,
text: document.getElementById("prompt-text").textContent,
banners: document.querySelectorAll("#prompt-banner").length,
};
});
check(shown.banners === 0, `${what} : un bandeau de copie`);
if (shown.copied) {
check(shown.label === "Copié" && shown.check, `${what} : bouton « ${shown.label} »`);
// La couleur du bouton est verdict-good (#rrggbb dans app.css).
const rgb = shown.good
.replace("#", "")
.match(/../g)
.map((h) => Number.parseInt(h, 16))
.join(", ");
check(shown.color === `rgb(${rgb})`, `${what} : couleur ${shown.color} pour ${shown.good}`);
// Deux secondes après, le bouton revient.
await until("« Copier le prompt » revenu", notCopied, null, 5_000);
const label = await page.textContent("#prompt-copy");
check(label === "Copier le prompt", `${what} : le bouton reste « ${label} »`);
}
if (full) {
check(shown.banner && shown.bannerText.includes("Prompt copié"), `${what} : pas de bandeau`);
check(shown.copied, `${what} : pas de « Copié » sur le bouton`);
const clipboard = await page.evaluate(() => navigator.clipboard.readText());
check(clipboard === shown.text, `${what} : le presse-papiers diffère du texte affiché`);
return clipboard;
}
// Firefox : la copie, ou son repli (texte sélectionné, et un message).
check(shown.banner || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
log(`${what} : ${shown.banner ? "copié" : "repli par sélection"}`);
check(shown.copied || shown.status.includes("sélectionné"), `${what} : ni copie ni repli`);
log(`${what} : ${shown.copied ? "copié" : "repli par sélection"}`);
return shown.text;
}
/** Ferme la feuille du prompt : l'écran d'en dessous revient. */
async function closePrompt(hashBelow) {
await page.click("#prompt-done");
/** « ‹ … » : la page du prompt s'en va, l'écran d'avant revient. */
async function backFromPrompt(hashBelow) {
await page.click("#prompt-back");
await promptGone(hashBelow);
}
/** La page du prompt n'est plus affichée, à l'adresse hashBelow. */
async function promptGone(hashBelow) {
await until(
"feuille du prompt fermée",
(below) => document.getElementById("prompt-veil").hidden && location.hash === below,
(below) =>
document.getElementById("prompt-veil").hidden &&
document.querySelector(".prompting") === null &&
location.hash === below,
hashBelow,
);
}
@@ -254,7 +337,16 @@ const sheetNumbers = await page.evaluate(() =>
await capture("1-bouton-feuille-du-maitre", () =>
page.locator("#explain-ask-ai").scrollIntoViewIfNeeded(),
);
// La feuille du maître, avant : sa hauteur, que la page du prompt garde.
const explainBox = await boxOf("#explain-sheet");
const moveText = await openPrompt("#explain-ask-ai", "/ia");
await checkOneSheet("explain-sheet", "Explication", "feuille du maître");
const promptBox = await boxOf("#prompt-page");
check(
Math.abs(promptBox.top - explainBox.top) <= 1 &&
Math.abs(promptBox.height - explainBox.height) <= 1,
`la feuille change de hauteur : ${JSON.stringify(explainBox)} puis ${JSON.stringify(promptBox)}`,
);
check((await page.evaluate(() => location.hash)) === `${explainHash}/ia`, "adresse de la feuille");
checkPrompt(moveText, "prompt du coup");
check(moveText.includes("J'ai joué 6/off : "), "prompt du coup : coup joué");
@@ -276,30 +368,39 @@ await capture("2-feuille-du-prompt");
const copied = await copyPrompt("prompt du coup");
await capture("3-prompt-copie", async () => {
await page.click("#prompt-copy");
await until("bandeau", () => !document.getElementById("prompt-banner").hidden);
await until("« Copié »", copiedOnButton);
});
log(`prompt du coup copié (${copied.length} caractères)`);
// Échap ferme la feuille du prompt, pas l'explication dessous.
// Échap ramène à l'explication, qui n'est pas fermée.
await page.keyboard.press("Escape");
await until(
"retour à l'explication",
(below) =>
document.getElementById("prompt-veil").hidden &&
!document.getElementById("explain-veil").hidden &&
location.hash === below,
explainHash,
await promptGone(explainHash);
check(
await page.evaluate(
() =>
!document.getElementById("explain-veil").hidden &&
getComputedStyle(document.getElementById("explain-close")).visibility === "visible",
),
"l'explication n'est pas revenue",
);
// Rechargée, l'adresse de la feuille la rouvre.
await page.goto(`${base}${explainHash}/ia`);
await page.reload();
await until(
"feuille rouverte au rechargement",
() => document.getElementById("prompt-sheet")?.dataset.ready === "true",
() => document.getElementById("prompt-page")?.dataset.ready === "true",
);
check((await page.textContent("#prompt-text")) === moveText, "le prompt rechargé diffère");
await closePrompt(explainHash);
await page.click("#explain-done");
await until("feuille du maître fermée", () => document.getElementById("explain-veil").hidden);
await checkOneSheet("explain-sheet", "Explication", "feuille du maître rechargée");
// « ‹ Explication » y ramène.
await backFromPrompt(explainHash);
// La croix ferme la feuille : l'explication avec.
await openPrompt("#explain-ask-ai", "/ia");
await page.click("#prompt-close");
await until(
"feuille du maître fermée",
() => document.getElementById("explain-veil").hidden && location.hash === "#/",
);
await promptGone("#/");
// ------------------------------------------------------------- Le bilan
@@ -318,6 +419,7 @@ const summaryHash = await page.evaluate(() => location.hash);
const id = summaryHash.split("/")[2];
await capture("4-bouton-bilan", () => page.locator("#summary-ask-ai").scrollIntoViewIfNeeded());
const summaryText = await openPrompt("#summary-ask-ai", "/bilan/ia");
await checkOneSheet("summary", "Bilan", "bilan");
checkPrompt(summaryText, "prompt du bilan");
check(summaryText.includes("Résultat : l'ordinateur a gagné"), "prompt du bilan : résultat");
check(
@@ -327,7 +429,9 @@ check(
check(summaryText.includes("Le coup à revoir, ma plus grosse erreur (coup 1)"), "coup à revoir");
await capture("5-feuille-du-prompt-bilan");
await copyPrompt("prompt du bilan");
await closePrompt(summaryHash);
// Le retour du téléphone ramène au bilan.
await page.goBack();
await promptGone(summaryHash);
check(
await page.evaluate(() => document.getElementById("summary")?.dataset.ready === "true"),
"le bilan n'est plus là après la feuille du prompt",
@@ -349,6 +453,7 @@ await capture("6-bouton-analyse", () =>
);
const analysisText = await openPrompt("#analysis-ask-ai", `/parties/${id}/analyse/1/ia`);
checkPrompt(analysisText, "prompt de l'analyse");
await checkOneSheet(null, "Analyse", "analyse");
// Le même coup, les mêmes chiffres que dans la feuille du maître (le niveau
// du joueur, lui, compte maintenant toutes ses décisions de la partie).
const numbersOf = (text) => text.slice(text.indexOf("J'ai joué"), text.indexOf("Ces évaluations"));
@@ -356,7 +461,7 @@ check(
numbersOf(analysisText) === numbersOf(moveText) && numbersOf(moveText).length > 50,
"les chiffres du coup diffèrent entre la feuille du maître et l'analyse",
);
await closePrompt(`#/parties/${id}/analyse/1`);
await backFromPrompt(`#/parties/${id}/analyse/1`);
// Coup 2 : le joueur a passé le double, une bonne décision ; son prompt de
// videau.
await page.goto(`${base}#/parties/${id}/analyse/2`);
@@ -368,7 +473,9 @@ const cubeText = await openPrompt("#analysis-ask-ai", "/analyse/2/ia");
checkPrompt(cubeText, "prompt du videau");
check(cubeText.includes("prendre ou passer le double de l'ordinateur"), "prompt du videau");
check(cubeText.includes("J'ai choisi de passer"), "prompt du videau : décision");
await closePrompt(`#/parties/${id}/analyse/2`);
// « Fermer » : sans feuille dessous, retour à l'analyse.
await page.click("#prompt-done");
await promptGone(`#/parties/${id}/analyse/2`);
// --------------------------------------------------------- Le glossaire
@@ -389,6 +496,7 @@ await capture("7-bouton-glossaire", () =>
);
const conceptText = await openPrompt("#glossary-ask-ai-videau", "#/glossaire/videau/ia");
checkPrompt(conceptText, "prompt du concept");
await checkOneSheet(null, "Glossaire", "glossaire");
check(conceptText.includes("le concept « Videau » (en anglais : Cube)"), "prompt du concept");
check(/chances de gagner \d+ %/.test(conceptText), "prompt du concept : évaluation");
await capture("8-feuille-du-prompt-glossaire");
@@ -396,24 +504,17 @@ await copyPrompt("prompt du concept");
if (full) {
// Le repli : navigator.clipboard refuse (comme hors contexte sûr) ; le
// texte est sélectionné dans la feuille et copié par le navigateur.
await until("bandeau parti", () => document.getElementById("prompt-banner").hidden);
await until("bouton revenu", notCopied, null, 5_000);
await page.evaluate(async () => {
await navigator.clipboard.writeText("");
navigator.clipboard.writeText = () => Promise.reject(new Error("refusé"));
});
await page.click("#prompt-copy");
await until(
"copie de repli",
() =>
!document.getElementById("prompt-banner").hidden ||
document.getElementById("prompt-status").textContent !== "",
null,
10_000,
);
await until("copie de repli", copyDone, null, 10_000);
const fallback = await page.evaluate(async () => ({
selected: window.getSelection().toString(),
text: document.getElementById("prompt-text").textContent,
banner: !document.getElementById("prompt-banner").hidden,
copied: document.getElementById("prompt-copy").classList.contains("copied"),
clipboard: await navigator.clipboard.readText(),
}));
// Copié depuis une sélection, le texte perd ses espaces insécables
@@ -421,12 +522,12 @@ if (full) {
const plain = (text) => text.replace(/ /g, " ").trim();
check(plain(fallback.selected) === plain(fallback.text), "repli : texte non sélectionné");
check(
fallback.banner && plain(fallback.clipboard) === plain(fallback.text),
`repli : le navigateur n'a pas copié le texte sélectionné (bandeau ${fallback.banner}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`,
fallback.copied && plain(fallback.clipboard) === plain(fallback.text),
`repli : le navigateur n'a pas copié le texte sélectionné (« Copié » ${fallback.copied}, presse-papiers ${fallback.clipboard.length} car. pour ${fallback.text.length})`,
);
log("repli par sélection du texte : copié");
}
await closePrompt("#/glossaire/videau");
await backFromPrompt("#/glossaire/videau");
const csp = await page.evaluate(() => window.__csp);
for (const violation of csp) {