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
+6 -3
View File
@@ -18,7 +18,7 @@ import {
} from "../domain/game/advice.js";
import type { Game } from "../domain/game/game.js";
import { ADVICE_TEXTS, CUBE_GLOSSARY, formatEquity } from "./advice-texts.js";
import { askAiLink } from "./ask-ai.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { type IconName, icon } from "./icons.js";
import { boardSvg, createMovePlayer, drawSteps, type MovePlayer } from "./move-board.js";
import { routeHash } from "./router.js";
@@ -48,6 +48,8 @@ export interface AdviceView {
/** Ouvre la feuille sur le conseil de l'événement eventId. */
show(eventId: string): Promise<void>;
hide(): void;
/** La feuille, où la page du prompt prend la place du contenu (1f). */
readonly sheet: HTMLElement;
}
type Shown = "mine" | "best";
@@ -92,7 +94,7 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
const id = ++opened;
const game = options.game();
const advice = game?.advice[eventId] ?? null;
sheet.replaceChildren();
clearSheet(sheet);
veil.hidden = false;
const head = element(sheet, "div", { className: "sheet-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
@@ -332,12 +334,13 @@ export function createAdviceView(options: AdviceViewOptions): AdviceView {
return {
show,
sheet,
hide() {
opened++;
player?.stop();
if (!veil.hidden) {
veil.hidden = true;
sheet.replaceChildren();
clearSheet(sheet);
}
},
};
+31 -6
View File
@@ -246,7 +246,6 @@ export function startApp(options: AppOptions): void {
explainVeil: byId("explain-veil"),
summaryVeil: byId("summary-veil"),
promptVeil: byId("prompt-veil"),
promptBanner: byId("prompt-banner"),
analysis: byId("analysis"),
menuAdvice: byId<HTMLButtonElement>("menu-advice"),
prompt: byId("prompt"),
@@ -1319,7 +1318,9 @@ export function startApp(options: AppOptions): void {
// ------------------------------------------- Demander à une IA (1f)
// La feuille du prompt, à l'adresse …/ia, au-dessus de l'écran d'où on le demande.
// La feuille du prompt, à l'adresse …/ia : à la place de la feuille du
// maître ou du bilan, ou dans sa propre feuille au-dessus de l'analyse et
// du glossaire. Une seule feuille à la fois.
const promptSource = createPromptSource({
access: options.prompts,
phrases: () => phrasesLoading,
@@ -1327,13 +1328,14 @@ export function startApp(options: AppOptions): void {
});
const promptView = createPromptView({
veil: ui.promptVeil,
banner: ui.promptBanner,
close: () => closePrompt(),
back: () => closePrompt(),
close: () => closePromptSheet(),
});
// L'adresse de la feuille ouverte (null : fermée).
let promptShown: string | null = null;
/** Ferme la feuille du prompt : retour à l'écran d'où on l'a ouverte. */
/** « ‹ Explication », Échap ou le retour du téléphone : retour à l'écran
* d'où on l'a ouverte. */
function closePrompt(): void {
const route = navigation.route;
promptView.hide();
@@ -1341,10 +1343,33 @@ export function startApp(options: AppOptions): void {
navigation.leave(isAiRoute, isAiRoute(route) ? routeHash(withoutAi(route)) : "#/");
}
/** La croix et « Fermer » : la feuille se ferme. Celle du maître ou le
* bilan, dont elle avait pris la place, se ferme avec elle. */
function closePromptSheet(): void {
const route = navigation.route;
if (route.screen === "explanation" || route.screen === "summary") {
promptView.hide();
promptShown = null;
leaveSheet();
return;
}
closePrompt();
}
/** Ouvre (ou met à jour) la feuille du prompt de l'adresse route. */
function showPrompt(route: AiRoute): void {
promptShown = routeHash(route);
promptView.show(promptOf(route));
const host =
route.screen === "explanation"
? adviceView.sheet
: route.screen === "summary"
? summaryView.panel
: null;
promptView.show(promptOf(route), {
host,
back: ASK_AI_TEXTS.back[route.screen],
backHref: routeHash(withoutAi(route)),
});
}
/** Le journal de la partie id, pour son bilan ou son analyse : celui de
+8 -1
View File
@@ -10,11 +10,18 @@ export const ASK_AI_TEXTS = {
lead: "Une question toute prête, avec la position et les chiffres de GNU Backgammon. Relisez-la, copiez-la, puis collez-la dans l'IA de votre choix : le moteur décide, l'IA explique.",
textLabel: "Le prompt, à relire avant de le copier",
copy: "Copier le prompt",
copied: "Prompt copié",
copied: "Copié",
notCopied:
"Le navigateur refuse la copie : le texte est sélectionné, copiez-le avec le menu de votre téléphone.",
preparing: "Préparation du prompt…",
close: "Fermer",
/** « ‹ Explication » : l'écran d'où l'on vient, pour y revenir. */
back: {
explanation: "Explication",
summary: "Bilan",
analysis: "Analyse",
glossary: "Glossaire",
},
failed: "Le prompt n'a pas pu être préparé.",
noAdvice: "Pas de prompt pour cette décision : le maître ne l'a pas encore analysée.",
notReady: "Le maître analyse encore la partie : le prompt sera prêt avec le bilan.",
+125 -61
View File
@@ -1,9 +1,15 @@
// « Demander à une IA » (étape 1f ; charte, ecrans-etape-1.md, « 1f ») :
// le bouton, secondaire, avec l'icône de copie ; et la feuille du prompt
// (…/ia, ADR 0013), où le texte se relit avant d'être copié. La copie passe
// par navigator.clipboard ; à défaut, le texte est sélectionné dans la
// feuille et copié par le navigateur, ou laissé sélectionné pour être
// copié à la main. Le bandeau « Prompt copié » le confirme.
// (…/ia, ADR 0013), où le texte se relit avant d'être copié.
//
// Une seule feuille à la fois (recette de 1f) : ouverte 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 y revenir ;
// depuis l'analyse ou le glossaire, qui ne sont pas des feuilles, elle monte
// dans sa propre feuille. La copie passe par navigator.clipboard ; à défaut,
// le texte est sélectionné dans la feuille et copié par le navigateur, ou
// laissé sélectionné pour être copié à la main. Le bouton la confirme
// (« ✓ Copié », 2 s).
//
// Copyright (C) 2026 les auteurs d'Ustagammon.
// Licence : GPLv3 ou ultérieure (voir LICENSE).
@@ -13,12 +19,15 @@
import { ASK_AI_TEXTS } from "./ask-ai-texts.js";
import { icon } from "./icons.js";
/** Durée du bandeau « Prompt copié » (charte : 2 s). */
/** Durée de « ✓ Copié » sur le bouton (charte : 2 s). */
export const COPIED_MS = 2000;
/** Ce que montre la feuille : le prompt, ou pourquoi il n'y en a pas. */
export type PromptResult = { readonly text: string } | { readonly message: string };
/** Classe de la page du prompt, posée dans une autre feuille. */
const PAGE_CLASS = "prompt-page";
/** Le bouton « Demander à une IA » : un lien vers la feuille du prompt. */
export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAnchorElement {
const link = document.createElement("a");
@@ -30,18 +39,36 @@ export function askAiLink(parent: HTMLElement, id: string, href: string): HTMLAn
return link;
}
/** Vide une feuille qui peut accueillir la page du prompt, sans l'ôter. */
export function clearSheet(sheet: HTMLElement): void {
for (const child of [...sheet.children]) {
if (!child.classList.contains(PAGE_CLASS)) {
child.remove();
}
}
}
/** D'où l'on vient : la feuille dont la page prend la place (null : sa
* propre feuille) et le nom de l'écran, pour « ‹ Explication ». */
export interface PromptPlace {
readonly host: HTMLElement | null;
readonly back: string;
/** L'adresse de l'écran d'où l'on vient (le lien de « ‹ … »). */
readonly backHref: string;
}
export interface PromptViewOptions {
/** Le voile de la feuille (index.html) ; la feuille est construite ici. */
/** Le voile de sa propre feuille (index.html) ; elle est construite ici. */
readonly veil: HTMLElement;
/** Le bandeau « Prompt copié » (index.html). */
readonly banner: HTMLElement;
/** Fermer : quitter l'adresse de la feuille. */
/** « ‹ Explication » : revenir à l'écran d'où l'on vient. */
readonly back: () => void;
/** La croix et « Fermer » : fermer la feuille. */
readonly close: () => void;
}
export interface PromptView {
/** Ouvre la feuille (ou la met à jour) sur un prompt en préparation. */
show(prompt: Promise<PromptResult>): void;
show(prompt: Promise<PromptResult>, place: PromptPlace): void;
hide(): void;
readonly isOpen: boolean;
/** Le prompt est affiché (pas un message). */
@@ -68,26 +95,42 @@ function element<K extends keyof HTMLElementTagNameMap>(
}
export function createPromptView(options: PromptViewOptions): PromptView {
const { veil, banner } = options;
const { veil } = options;
// Sa propre feuille, pour l'analyse et le glossaire.
const sheet = element(veil, "div", { id: "prompt-sheet", className: "sheet prompt-sheet" });
sheet.setAttribute("role", "dialog");
sheet.setAttribute("aria-modal", "true");
sheet.setAttribute("aria-labelledby", "prompt-title");
const head = element(sheet, "div", { className: "sheet-head" });
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
// La page du prompt, posée dans sa feuille ou dans celle d'où l'on vient.
const page = element(sheet, "div", { id: "prompt-page", className: PAGE_CLASS });
page.setAttribute("role", "dialog");
page.setAttribute("aria-modal", "true");
page.setAttribute("aria-labelledby", "prompt-title");
const head = element(page, "div", { className: "sheet-head prompt-head" });
element(head, "span", { className: "grip" }).setAttribute("aria-hidden", "true");
element(head, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title });
const back = element(head, "a", { id: "prompt-back", className: "prompt-back" });
back.addEventListener("click", (event) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
options.back();
});
const closeIcon = element(head, "button", { id: "prompt-close", className: "icon-button" });
closeIcon.type = "button";
closeIcon.append(icon("close"));
closeIcon.setAttribute("aria-label", ASK_AI_TEXTS.close);
closeIcon.onclick = () => options.close();
element(sheet, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead });
const text = element(sheet, "pre", { id: "prompt-text", className: "prompt-text" });
element(page, "h2", { id: "prompt-title", className: "sheet-title", text: ASK_AI_TEXTS.title });
element(page, "p", { className: "prompt-lead", text: ASK_AI_TEXTS.lead });
const text = element(page, "pre", { id: "prompt-text", className: "prompt-text" });
text.tabIndex = 0;
text.setAttribute("aria-label", ASK_AI_TEXTS.textLabel);
const status = element(sheet, "p", { id: "prompt-status", className: "prompt-status" });
const status = element(page, "p", { id: "prompt-status", className: "prompt-status" });
status.setAttribute("role", "status");
const actions = element(sheet, "div", { className: "prompt-actions" });
const actions = element(page, "div", { className: "prompt-actions" });
const done = element(actions, "button", {
id: "prompt-done",
className: "secondary",
@@ -97,43 +140,53 @@ export function createPromptView(options: PromptViewOptions): PromptView {
done.onclick = () => options.close();
const copy = element(actions, "button", { id: "prompt-copy" });
copy.type = "button";
copy.append(icon("copy"), ASK_AI_TEXTS.copy);
veil.addEventListener("click", (event) => {
if (event.target === veil) {
options.close();
}
});
const bannerText = element(banner, "span", { text: ASK_AI_TEXTS.copied });
banner.prepend(icon("verdictGood"));
const timer = element(banner, "span", { className: "banner-timer prompt-timer" });
timer.setAttribute("aria-hidden", "true");
let bannerTimeout: ReturnType<typeof setTimeout> | null = null;
// Le libellé du bouton est annoncé quand il passe à « Copié ».
const copyLabel = document.createElement("span");
copyLabel.setAttribute("aria-live", "polite");
// Chaque ouverture a son numéro : un prompt qui arrive après une autre
// ouverture, ou après la fermeture, n'est pas montré.
let opened = 0;
let ready = false;
let open = false;
// La feuille d'où l'on vient, dont la page a pris la place.
let host: HTMLElement | null = null;
let copiedTimeout: ReturnType<typeof setTimeout> | null = null;
function showBanner(): void {
bannerText.textContent = ASK_AI_TEXTS.copied;
banner.hidden = false;
// La barre de temps repart de zéro (charte : 2 s).
timer.classList.remove("running");
void timer.offsetWidth;
timer.classList.add("running");
if (bannerTimeout !== null) {
clearTimeout(bannerTimeout);
/** « Copier le prompt », ou « ✓ Copié » pendant 2 s. */
function showCopied(copied: boolean): void {
if (copiedTimeout !== null) {
clearTimeout(copiedTimeout);
copiedTimeout = null;
}
copy.classList.toggle("copied", copied);
copyLabel.textContent = copied ? ASK_AI_TEXTS.copied : ASK_AI_TEXTS.copy;
copy.replaceChildren(icon(copied ? "verdictGood" : "copy"), copyLabel);
if (copied) {
copiedTimeout = setTimeout(() => showCopied(false), COPIED_MS);
}
bannerTimeout = setTimeout(hideBanner, COPIED_MS);
}
showCopied(false);
function hideBanner(): void {
banner.hidden = true;
timer.classList.remove("running");
if (bannerTimeout !== null) {
clearTimeout(bannerTimeout);
bannerTimeout = null;
/** Pose la page dans la feuille d'où l'on vient, ou dans la sienne. */
function place(next: HTMLElement | null): void {
if (host !== next) {
host?.classList.remove("prompting");
host = next;
}
if (next === null) {
if (page.parentElement !== sheet) {
sheet.append(page);
}
veil.hidden = false;
return;
}
veil.hidden = true;
// La page couvre la feuille telle qu'elle s'affiche, défilée en haut.
next.scrollTop = 0;
next.classList.add("prompting");
if (page.parentElement !== next) {
next.append(page);
}
}
@@ -177,25 +230,31 @@ export function createPromptView(options: PromptViewOptions): PromptView {
}
if (copied) {
status.textContent = "";
showBanner();
showCopied(true);
} else {
selectText();
showCopied(false);
status.textContent = ASK_AI_TEXTS.notCopied;
}
};
return {
show(prompt) {
show(prompt, where) {
const id = ++opened;
const opening = veil.hidden;
veil.hidden = false;
const opening = !open || host !== where.host;
open = true;
back.textContent = `‹ ${where.back}`;
back.href = where.backHref;
place(where.host);
if (opening) {
ready = false;
text.textContent = "";
text.hidden = true;
copy.disabled = true;
showCopied(false);
status.textContent = ASK_AI_TEXTS.preparing;
closeIcon.focus();
delete page.dataset.ready;
back.focus();
}
prompt.then(
(result) => {
@@ -208,13 +267,13 @@ export function createPromptView(options: PromptViewOptions): PromptView {
text.hidden = false;
copy.disabled = false;
status.textContent = "";
sheet.dataset.ready = "true";
page.dataset.ready = "true";
} else {
ready = false;
text.hidden = true;
copy.disabled = true;
status.textContent = result.message;
delete sheet.dataset.ready;
delete page.dataset.ready;
}
},
() => {
@@ -229,15 +288,20 @@ export function createPromptView(options: PromptViewOptions): PromptView {
hide() {
opened++;
ready = false;
// Le bandeau « Prompt copié » finit ses 2 s, feuille fermée.
if (!veil.hidden) {
veil.hidden = true;
text.textContent = "";
delete sheet.dataset.ready;
if (!open) {
return;
}
open = false;
showCopied(false);
host?.classList.remove("prompting");
host = null;
veil.hidden = true;
sheet.append(page);
text.textContent = "";
delete page.dataset.ready;
},
get isOpen() {
return !veil.hidden;
return open;
},
get ready() {
return ready;
+6 -3
View File
@@ -28,7 +28,7 @@ import type { GameJournal } from "../domain/game/events.js";
import type { Game } from "../domain/game/game.js";
import { COMPUTER_LEVELS } from "../domain/game/level.js";
import { VERDICT_ICONS } from "./advice-view.js";
import { askAiLink } from "./ask-ai.js";
import { askAiLink, clearSheet } from "./ask-ai.js";
import { icon } from "./icons.js";
import { MATCH_TEXTS } from "./match-texts.js";
import { boardSvg, drawSteps } from "./move-board.js";
@@ -65,6 +65,8 @@ export interface SummaryView {
show(data: SummaryData | null): void;
hide(): void;
readonly isOpen: boolean;
/** Le panneau, où la page du prompt prend la place du bilan (1f). */
readonly panel: HTMLElement;
}
function element<K extends keyof HTMLElementTagNameMap>(
@@ -142,7 +144,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
return;
}
drawn = key;
panel.replaceChildren();
clearSheet(panel);
delete panel.dataset.ready;
const head = element(panel, "div", { className: "summary-head" });
element(head, "p", { className: "overline", text: TEXTS.endOverline });
@@ -356,7 +358,7 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
hide() {
if (!veil.hidden) {
veil.hidden = true;
panel.replaceChildren();
clearSheet(panel);
delete panel.dataset.ready;
drawn = "";
}
@@ -364,5 +366,6 @@ export function createSummaryView(options: SummaryViewOptions): SummaryView {
get isOpen() {
return !veil.hidden;
},
panel,
};
}